From 4bbf391109a162bbe313564bae130bbab359989e Mon Sep 17 00:00:00 2001
From: Violette Roche-Montane <violette.roche-montane@forgerock.com>
Date: Wed, 12 Feb 2014 16:11:06 +0000
Subject: [PATCH] Checkpoint commit for OPENDJ-1343 Migrate dsconfig / OPENDJ-1303 "opendj-cli" - added classes needeed by DSConfig.

---
 opendj-cli/src/main/java/com/forgerock/opendj/cli/TableBuilder.java               |  328 +++++++
 opendj-cli/src/main/java/com/forgerock/opendj/cli/ValidationCallback.java         |   53 +
 opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuResult.java                 |  261 +++++
 opendj-cli/src/main/java/com/forgerock/opendj/cli/Menu.java                       |   48 +
 opendj-cli/src/main/java/com/forgerock/opendj/cli/ConsoleApplication.java         |   84 +
 opendj-cli/src/test/java/com/forgerock/opendj/cli/ConsoleApplicationTestCase.java |   44 
 opendj-cli/src/main/java/com/forgerock/opendj/cli/TablePrinter.java               |   50 +
 opendj-cli/src/main/java/com/forgerock/opendj/cli/TextTablePrinter.java           |  497 +++++++++++
 opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java                      |   48 +
 opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuBuilder.java                |  732 ++++++++++++++++
 opendj-cli/src/main/java/com/forgerock/opendj/cli/CommandBuilder.java             |  272 ++++++
 opendj-cli/src/main/java/com/forgerock/opendj/cli/HelpCallback.java               |   41 
 opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuCallback.java               |   51 +
 opendj-cli/src/main/java/com/forgerock/opendj/cli/CommonArguments.java            |    5 
 opendj-cli/src/main/java/com/forgerock/opendj/cli/TableSerializer.java            |  134 ++
 opendj-cli/src/main/resources/com/forgerock/opendj/cli/cli.properties             |   21 
 16 files changed, 2,648 insertions(+), 21 deletions(-)

diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/CommandBuilder.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/CommandBuilder.java
new file mode 100644
index 0000000..a4da3f1
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/CommandBuilder.java
@@ -0,0 +1,272 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008-2009 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+import static com.forgerock.opendj.cli.Utils.OBFUSCATED_VALUE;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+import com.forgerock.opendj.util.OperatingSystem;
+
+/**
+ * Class used to be able to generate the non interactive mode.
+ */
+public class CommandBuilder {
+    private String commandName;
+    private String subcommandName;
+    private ArrayList<Argument> args;
+    private HashSet<Argument> obfuscatedArgs;
+
+    /**
+     * The separator used to link the lines of the resulting command-lines.
+     */
+    public final static String LINE_SEPARATOR;
+    static {
+        if (OperatingSystem.isWindows()) {
+            LINE_SEPARATOR = " ";
+        } else {
+            LINE_SEPARATOR = " \\\n          ";
+        }
+    }
+
+    /**
+     * The separator used to link the lines of the resulting command-lines in HTML format.
+     */
+    public final static String HTML_LINE_SEPARATOR;
+    static {
+        if (OperatingSystem.isWindows()) {
+            HTML_LINE_SEPARATOR = "&nbsp;";
+        } else {
+            HTML_LINE_SEPARATOR = "&nbsp;\\<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
+        }
+    }
+
+    /**
+     * The constructor for the CommandBuilder.
+     *
+     * @param commandName
+     *            The command name.
+     * @param subcommandName
+     *            The sub command name.
+     */
+    public CommandBuilder(String commandName, String subcommandName) {
+        this.commandName = commandName;
+        this.subcommandName = subcommandName;
+        args = new ArrayList<Argument>();
+        obfuscatedArgs = new HashSet<Argument>();
+    }
+
+    /**
+     * Adds an argument to the list of the command builder.
+     *
+     * @param argument
+     *            The argument to be added.
+     */
+    public void addArgument(final Argument argument) {
+        // We use an ArrayList to be able to provide the possibility of updating
+        // the position of the attributes.
+        if (!args.contains(argument)) {
+            args.add(argument);
+        }
+    }
+
+    /**
+     * Adds an argument whose values must be obfuscated (passwords for instance).
+     *
+     * @param argument
+     *            The argument to be added.
+     */
+    public void addObfuscatedArgument(final Argument argument) {
+        addArgument(argument);
+        obfuscatedArgs.add(argument);
+    }
+
+    /**
+     * Removes the provided argument from this CommandBuilder.
+     *
+     * @param argument
+     *            The argument to be removed.
+     * @return <CODE>true</CODE> if the attribute was present and removed and <CODE>false</CODE> otherwise.
+     */
+    public boolean removeArgument(final Argument argument) {
+        obfuscatedArgs.remove(argument);
+        return args.remove(argument);
+    }
+
+    /**
+     * Appends the arguments of another command builder to this command builder.
+     *
+     * @param builder
+     *            The CommandBuilder to append.
+     */
+    public void append(final CommandBuilder builder) {
+        for (final Argument arg : builder.args) {
+            if (builder.isObfuscated(arg)) {
+                addObfuscatedArgument(arg);
+            } else {
+                addArgument(arg);
+            }
+        }
+    }
+
+    /**
+     * Returns the String representation of this command builder (i.e. what we want to show to the user).
+     *
+     * @return The String representation of this command builder (i.e. what we want to show to the user).
+     */
+    public String toString() {
+        return toString(false, LINE_SEPARATOR);
+    }
+
+    /**
+     * Returns the String representation of this command builder (i.e. what we want to show to the user).
+     *
+     * @param lineSeparator
+     *            The String to be used to separate lines of the command-builder.
+     * @return The String representation of this command builder (i.e. what we want to show to the user).
+     */
+    public String toString(final String lineSeparator) {
+        return toString(false, lineSeparator);
+    }
+
+    /**
+     * Returns the String representation of this command builder (i.e. what we want to show to the user).
+     *
+     * @param showObfuscated
+     *            Displays in clear the obfuscated values.
+     * @param lineSeparator
+     *            The String to be used to separate lines of the command-builder.
+     * @return The String representation of this command builder (i.e. what we want to show to the user).
+     */
+    private String toString(final boolean showObfuscated, final String lineSeparator) {
+        final StringBuilder builder = new StringBuilder();
+        builder.append(commandName);
+        if (subcommandName != null) {
+            builder.append(" " + subcommandName);
+        }
+        for (final Argument arg : args) {
+            // This CLI is always using SSL, and the argument has been removed from
+            // the user interface
+            if (arg.getName().equals("useSSL")) {
+                continue;
+            }
+            String argName;
+            if (arg.getLongIdentifier() != null) {
+                argName = "--" + arg.getLongIdentifier();
+            } else {
+                argName = "-" + arg.getShortIdentifier();
+            }
+
+            if (arg instanceof BooleanArgument) {
+                builder.append(lineSeparator + argName);
+            } else if (arg instanceof FileBasedArgument) {
+                for (String value : ((FileBasedArgument) arg).getNameToValueMap().keySet()) {
+                    builder.append(lineSeparator + argName + " ");
+                    if (isObfuscated(arg) && !showObfuscated) {
+                        value = OBFUSCATED_VALUE;
+                    } else {
+                        value = escapeValue(value);
+                    }
+                    builder.append(value);
+                }
+            } else {
+                for (String value : arg.getValues()) {
+                    builder.append(lineSeparator + argName + " ");
+                    if (isObfuscated(arg) && !showObfuscated) {
+                        value = OBFUSCATED_VALUE;
+                    } else {
+                        value = escapeValue(value);
+                    }
+                    builder.append(value);
+                }
+            }
+        }
+        return builder.toString();
+    }
+
+    /**
+     * Clears the arguments.
+     */
+    public void clearArguments() {
+        args.clear();
+        obfuscatedArgs.clear();
+    }
+
+    /**
+     * Returns the list of arguments.
+     *
+     * @return The list of arguments.
+     */
+    public List<Argument> getArguments() {
+        return args;
+    }
+
+    /**
+     * Tells whether the provided argument's values must be obfuscated or not.
+     *
+     * @param argument
+     *            The argument to handle.
+     * @return <CODE>true</CODE> if the attribute's values must be obfuscated and <CODE>false</CODE> otherwise.
+     */
+    public boolean isObfuscated(final Argument argument) {
+        return obfuscatedArgs.contains(argument);
+    }
+
+    // Chars that require special treatment when passing them to command-line.
+    private final static char[] CHARSTOESCAPE =
+    { ' ', '\t', '\n', '|', ';', '<', '>', '(', ')', '$', '`', '\\', '"', '\'' };
+
+    /**
+     * This method simply takes a value and tries to transform it (with escape or '"') characters so that it can be used
+     * in a command line.
+     *
+     * @param value
+     *            The String to be treated.
+     * @return The transformed value.
+     */
+    public static String escapeValue(String value) {
+        final StringBuilder b = new StringBuilder();
+        if (OperatingSystem.isUnix()) {
+            for (int i = 0; i < value.length(); i++) {
+                final char c = value.charAt(i);
+                boolean charToEscapeFound = false;
+                for (int j = 0; j < CHARSTOESCAPE.length && !charToEscapeFound; j++) {
+                    charToEscapeFound = c == CHARSTOESCAPE[j];
+                }
+                if (charToEscapeFound) {
+                    b.append('\\');
+                }
+                b.append(c);
+            }
+        } else {
+            b.append('"').append(value).append('"');
+        }
+        return b.toString();
+    }
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/CommonArguments.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/CommonArguments.java
index e57d835..f400649 100644
--- a/opendj-cli/src/main/java/com/forgerock/opendj/cli/CommonArguments.java
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/CommonArguments.java
@@ -123,8 +123,11 @@
      *             If there is a problem with any of the parameters used to create this argument.
      */
     public static final BooleanArgument getQuiet() throws ArgumentException {
-        return new BooleanArgument(OPTION_LONG_QUIET, OPTION_SHORT_QUIET, OPTION_LONG_QUIET,
+        final BooleanArgument quiet = new BooleanArgument(OPTION_LONG_QUIET, OPTION_SHORT_QUIET, OPTION_LONG_QUIET,
                 INFO_DESCRIPTION_QUIET.get());
+        quiet.setPropertyName(OPTION_LONG_QUIET);
+        return quiet;
+
     }
 
     /**
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/ConsoleApplication.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/ConsoleApplication.java
index 3811989..a17ca6f 100755
--- a/opendj-cli/src/main/java/com/forgerock/opendj/cli/ConsoleApplication.java
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/ConsoleApplication.java
@@ -30,6 +30,7 @@
 import static com.forgerock.opendj.cli.CliMessages.INFO_ERROR_EMPTY_RESPONSE;
 import static com.forgerock.opendj.cli.CliMessages.INFO_MENU_PROMPT_RETURN_TO_CONTINUE;
 import static com.forgerock.opendj.cli.CliMessages.INFO_PROMPT_SINGLE_DEFAULT;
+import static com.forgerock.opendj.cli.CliMessages.ERR_TRIES_LIMIT_REACHED;
 import static com.forgerock.opendj.cli.Utils.MAX_LINE_WIDTH;
 import static com.forgerock.opendj.cli.Utils.wrapText;
 
@@ -144,6 +145,15 @@
     }
 
     /**
+     * Indicates whether or not the user has requested advanced mode.
+     *
+     * @return Returns <code>true</code> if the user has requested advanced mode.
+     */
+    public boolean isAdvancedMode() {
+        return false;
+    }
+
+    /**
      * Interactively prompts the user to press return to continue. This method should be called in situations where a
      * user needs to be given a chance to read some documentation before continuing (continuing may cause the
      * documentation to be scrolled out of view).
@@ -215,14 +225,18 @@
      *            The message.
      */
     public final void print(final LocalizableMessage msg) {
-        out.print(wrap(msg));
+        if (!isQuiet()) {
+            out.print(wrap(msg));
+        }
     }
 
     /**
      * Displays a blank line to the output stream.
      */
     public final void println() {
-        out.println();
+        if (!isQuiet()) {
+            out.println();
+        }
     }
 
     /**
@@ -232,7 +246,9 @@
      *            The message.
      */
     public final void println(final LocalizableMessage msg) {
-        out.println(wrap(msg));
+        if (!isQuiet()) {
+            out.println(wrap(msg));
+        }
     }
 
     /**
@@ -244,7 +260,9 @@
      *            The number of columns to indent.
      */
     public final void println(final LocalizableMessage msg, final int indent) {
-        out.println(wrapText(msg, MAX_LINE_WIDTH, indent));
+        if (!isQuiet()) {
+            out.println(wrapText(msg, MAX_LINE_WIDTH, indent));
+        }
     }
 
     /**
@@ -254,7 +272,7 @@
      *            The verbose message.
      */
     public final void printVerboseMessage(final LocalizableMessage msg) {
-        if (isVerbose() || isInteractive()) {
+        if (isVerbose()) {
             out.println(wrap(msg));
         }
     }
@@ -330,7 +348,7 @@
      * @throws ClientException
      *             If the line of input could not be retrieved for some reason.
      */
-    private final String readLineOfInput(final LocalizableMessage prompt) throws ClientException {
+    final String readLineOfInput(final LocalizableMessage prompt) throws ClientException {
         if (prompt != null) {
             err.print(wrap(prompt));
             err.print(" ");
@@ -348,6 +366,60 @@
     }
 
     /**
+     * Interactively prompts for user input and continues until valid input is provided.
+     *
+     * @param <T>
+     *            The type of decoded user input.
+     * @param prompt
+     *            The interactive prompt which should be displayed on each input attempt.
+     * @param validator
+     *            An input validator responsible for validating and decoding the user's response.
+     * @return Returns the decoded user's response.
+     * @throws ClientException
+     *             If an unexpected error occurred which prevented validation.
+     */
+    public final <T> T readValidatedInput(final LocalizableMessage prompt, final ValidationCallback<T> validator)
+            throws ClientException {
+        while (true) {
+            final String response = readLineOfInput(prompt);
+            final T value = validator.validate(this, response);
+            if (value != null) {
+                return value;
+            }
+        }
+    }
+
+    /**
+     * Interactively prompts for user input and continues until valid input is provided.
+     *
+     * @param <T>
+     *            The type of decoded user input.
+     * @param prompt
+     *            The interactive prompt which should be displayed on each input attempt.
+     * @param validator
+     *            An input validator responsible for validating and decoding the user's response.
+     * @param maxTries
+     *            The maximum number of tries that we can make.
+     * @return Returns the decoded user's response.
+     * @throws ClientException
+     *             If an unexpected error occurred which prevented validation or
+     *             if the maximum number of tries was reached.
+     */
+    public final <T> T readValidatedInput(final LocalizableMessage prompt, final ValidationCallback<T> validator,
+            final int maxTries) throws ClientException {
+        int nTries = 0;
+        while (nTries < maxTries) {
+            final String response = readLineOfInput(prompt);
+            final T value = validator.validate(this, response);
+            if (value != null) {
+                return value;
+            }
+            nTries++;
+        }
+        throw new ClientException(ReturnCode.ERROR_USER_DATA, ERR_TRIES_LIMIT_REACHED.get(maxTries));
+    }
+
+    /**
      * Inserts line breaks into the provided buffer to wrap text at no more than the specified column width (80).
      *
      * @param msg
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/HelpCallback.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/HelpCallback.java
new file mode 100644
index 0000000..05659ce
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/HelpCallback.java
@@ -0,0 +1,41 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+/**
+ * An interface for displaying help interactively.
+ */
+public interface HelpCallback {
+
+    /**
+     * Displays help to the provided application console.
+     *
+     * @param app
+     *            The console application.
+     */
+    void display(ConsoleApplication app);
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/Menu.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/Menu.java
new file mode 100644
index 0000000..abd62b2
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/Menu.java
@@ -0,0 +1,48 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+/**
+ * An interactive console-based menu.
+ *
+ * @param <T>
+ *          The type of success result value(s) returned by the
+ *          call-back. Use <code>Void</code> if the call-backs do
+ *          not return any values.
+ */
+public interface Menu<T> {
+
+    /**
+     * Displays the menu and waits for the user to select a valid option. When the user selects an option, the call-back
+     * associated with the option will be invoked and its result returned.
+     *
+     * @return Returns the result of invoking the chosen menu call-back.
+     * @throws ClientException
+     *             If an I/O exception occurred or if one of the menu option call-backs failed for some reason.
+     */
+    MenuResult<T> run() throws ClientException;
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuBuilder.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuBuilder.java
new file mode 100644
index 0000000..5da49e6
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuBuilder.java
@@ -0,0 +1,732 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2007-2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+import static com.forgerock.opendj.cli.CliMessages.*;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.forgerock.i18n.LocalizableMessage;
+
+/**
+ * An interface for incrementally building a command-line menu.
+ *
+ * @param <T>
+ *            The type of value returned by the call-backs. Use <code>Void</code> if the call-backs do not return a
+ *            value.
+ */
+public final class MenuBuilder<T> {
+
+    /**
+     * A simple menu option call-back which is a composite of zero or more underlying call-backs.
+     *
+     * @param <T>
+     *            The type of value returned by the call-back.
+     */
+    private static final class CompositeCallback<T> implements MenuCallback<T> {
+
+        // The list of underlying call-backs.
+        private final Collection<MenuCallback<T>> callbacks;
+
+        /**
+         * Creates a new composite call-back with the specified set of call-backs.
+         *
+         * @param callbacks
+         *            The set of call-backs.
+         */
+        public CompositeCallback(Collection<MenuCallback<T>> callbacks) {
+            this.callbacks = callbacks;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        public MenuResult<T> invoke(ConsoleApplication app) throws ClientException {
+            List<T> values = new ArrayList<T>();
+            for (MenuCallback<T> callback : callbacks) {
+                MenuResult<T> result = callback.invoke(app);
+
+                if (!result.isSuccess()) {
+                    // Throw away all the other results.
+                    return result;
+                } else {
+                    values.addAll(result.getValues());
+                }
+            }
+            return MenuResult.success(values);
+        }
+    }
+
+    /**
+     * Underlying menu implementation generated by this menu builder.
+     *
+     * @param <T>
+     *            The type of value returned by the call-backs. Use <code>Void</code> if the call-backs do not return a
+     *            value.
+     */
+    private static final class MenuImpl<T> implements Menu<T> {
+
+        // Indicates whether the menu will allow selection of multiple
+        // numeric options.
+        private final boolean allowMultiSelect;
+
+        // The application console.
+        private final ConsoleApplication app;
+
+        // The call-back lookup table.
+        private final Map<String, MenuCallback<T>> callbacks;
+
+        // The char options table builder.
+        private final TableBuilder cbuilder;
+
+        // The call-back for the optional default action.
+        private final MenuCallback<T> defaultCallback;
+
+        // The description of the optional default action.
+        private final LocalizableMessage defaultDescription;
+
+        // The numeric options table builder.
+        private final TableBuilder nbuilder;
+
+        // The table printer.
+        private final TablePrinter printer;
+
+        // The menu prompt.
+        private final LocalizableMessage prompt;
+
+        // The menu title.
+        private final LocalizableMessage title;
+
+        // The maximum number of times we display the menu if the user provides
+        // bad input (-1 for unlimited).
+        private int nMaxTries;
+
+        // Private constructor.
+        private MenuImpl(ConsoleApplication app, LocalizableMessage title, LocalizableMessage prompt,
+                TableBuilder ntable, TableBuilder ctable, TablePrinter printer, Map<String, MenuCallback<T>> callbacks,
+                boolean allowMultiSelect, MenuCallback<T> defaultCallback, LocalizableMessage defaultDescription,
+                int nMaxTries) {
+            this.app = app;
+            this.title = title;
+            this.prompt = prompt;
+            this.nbuilder = ntable;
+            this.cbuilder = ctable;
+            this.printer = printer;
+            this.callbacks = callbacks;
+            this.allowMultiSelect = allowMultiSelect;
+            this.defaultCallback = defaultCallback;
+            this.defaultDescription = defaultDescription;
+            this.nMaxTries = nMaxTries;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        public MenuResult<T> run() throws ClientException {
+            // The validation call-back which will be used to determine the
+            // action call-back.
+            ValidationCallback<MenuCallback<T>> validator = new ValidationCallback<MenuCallback<T>>() {
+
+                public MenuCallback<T> validate(ConsoleApplication app, String input) {
+                    String ninput = input.trim();
+
+                    if (ninput.length() == 0) {
+                        if (defaultCallback != null) {
+                            return defaultCallback;
+                        } else if (allowMultiSelect) {
+                            app.println();
+                            app.println(ERR_MENU_BAD_CHOICE_MULTI.get());
+                            app.println();
+                            return null;
+                        } else {
+                            app.println();
+                            app.println(ERR_MENU_BAD_CHOICE_SINGLE.get());
+                            app.println();
+                            return null;
+                        }
+                    } else if (allowMultiSelect) {
+                        // Use a composite call-back to collect all the results.
+                        List<MenuCallback<T>> cl = new ArrayList<MenuCallback<T>>();
+                        for (String value : ninput.split(",")) {
+                            // Make sure that there are no duplicates.
+                            String nvalue = value.trim();
+                            Set<String> choices = new HashSet<String>();
+
+                            if (choices.contains(nvalue)) {
+                                app.println();
+                                app.println(ERR_MENU_BAD_CHOICE_MULTI_DUPE.get(value));
+                                app.println();
+                                return null;
+                            } else if (!callbacks.containsKey(nvalue)) {
+                                app.println();
+                                app.println(ERR_MENU_BAD_CHOICE_MULTI.get());
+                                app.println();
+                                return null;
+                            } else {
+                                cl.add(callbacks.get(nvalue));
+                                choices.add(nvalue);
+                            }
+                        }
+
+                        return new CompositeCallback<T>(cl);
+                    } else if (!callbacks.containsKey(ninput)) {
+                        app.println();
+                        app.println(ERR_MENU_BAD_CHOICE_SINGLE.get());
+                        app.println();
+                        return null;
+                    } else {
+                        return callbacks.get(ninput);
+                    }
+                }
+            };
+
+            // Determine the correct choice prompt.
+            LocalizableMessage promptMsg;
+            if (allowMultiSelect) {
+                if (defaultDescription != null) {
+                    promptMsg = INFO_MENU_PROMPT_MULTI_DEFAULT.get(defaultDescription);
+                } else {
+                    promptMsg = INFO_MENU_PROMPT_MULTI.get();
+                }
+            } else {
+                if (defaultDescription != null) {
+                    promptMsg = INFO_MENU_PROMPT_SINGLE_DEFAULT.get(defaultDescription);
+                } else {
+                    promptMsg = INFO_MENU_PROMPT_SINGLE.get();
+                }
+            }
+
+            // If the user selects help then we need to loop around and
+            // display the menu again.
+            while (true) {
+                // Display the menu.
+                if (title != null) {
+                    app.println(title);
+                    app.println();
+                }
+
+                if (prompt != null) {
+                    app.println(prompt);
+                    app.println();
+                }
+
+                if (nbuilder.getTableHeight() > 0) {
+                    nbuilder.print(printer);
+                    app.println();
+                }
+
+                if (cbuilder.getTableHeight() > 0) {
+                    TextTablePrinter cprinter = new TextTablePrinter(app.getErrorStream());
+                    cprinter.setDisplayHeadings(false);
+                    int sz = String.valueOf(nbuilder.getTableHeight()).length() + 1;
+                    cprinter.setIndentWidth(4);
+                    cprinter.setColumnWidth(0, sz);
+                    cprinter.setColumnWidth(1, 0);
+                    cbuilder.print(cprinter);
+                    app.println();
+                }
+
+                // Get the user's choice.
+                MenuCallback<T> choice;
+
+                if (nMaxTries != -1) {
+                    choice = app.readValidatedInput(promptMsg, validator, nMaxTries);
+                } else {
+                    choice = app.readValidatedInput(promptMsg, validator);
+                }
+
+                // Invoke the user's selected choice.
+                MenuResult<T> result = choice.invoke(app);
+
+                // Determine if the help needs to be displayed, display it and
+                // start again.
+                if (!result.isAgain()) {
+                    return result;
+                } else {
+                    app.println();
+                    app.println();
+                }
+            }
+        }
+    }
+
+    /**
+     * A simple menu option call-back which does nothing but return the provided menu result.
+     *
+     * @param <T>
+     *            The type of result returned by the call-back.
+     */
+    private static final class ResultCallback<T> implements MenuCallback<T> {
+
+        // The result to be returned by this call-back.
+        private final MenuResult<T> result;
+
+        // Private constructor.
+        private ResultCallback(MenuResult<T> result) {
+            this.result = result;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        public MenuResult<T> invoke(ConsoleApplication app) throws ClientException {
+            return result;
+        }
+
+    }
+
+    // The multiple column display threshold.
+    private int threshold = -1;
+
+    // Indicates whether the menu will allow selection of multiple
+    // numeric options.
+    private boolean allowMultiSelect = false;
+
+    // The application console.
+    private final ConsoleApplication app;
+
+    // The char option call-backs.
+    private final List<MenuCallback<T>> charCallbacks = new ArrayList<MenuCallback<T>>();
+
+    // The char option keys (must be single-character messages).
+    private final List<LocalizableMessage> charKeys = new ArrayList<LocalizableMessage>();
+
+    // The synopsis of char options.
+    private final List<LocalizableMessage> charSynopsis = new ArrayList<LocalizableMessage>();
+
+    // Optional column headings.
+    private final List<LocalizableMessage> columnHeadings = new ArrayList<LocalizableMessage>();
+
+    // Optional column widths.
+    private final List<Integer> columnWidths = new ArrayList<Integer>();
+
+    // The call-back for the optional default action.
+    private MenuCallback<T> defaultCallback = null;
+
+    // The description of the optional default action.
+    private LocalizableMessage defaultDescription = null;
+
+    // The numeric option call-backs.
+    private final List<MenuCallback<T>> numericCallbacks = new ArrayList<MenuCallback<T>>();
+
+    // The numeric option fields.
+    private final List<List<LocalizableMessage>> numericFields = new ArrayList<List<LocalizableMessage>>();
+
+    // The menu title.
+    private LocalizableMessage title = null;
+
+    // The menu prompt.
+    private LocalizableMessage prompt = null;
+
+    // The maximum number of times that we allow the user to provide an invalid
+    // answer (-1 if unlimited).
+    private int nMaxTries = -1;
+
+    /**
+     * Creates a new menu.
+     *
+     * @param app
+     *            The application console.
+     */
+    public MenuBuilder(ConsoleApplication app) {
+        this.app = app;
+    }
+
+    /**
+     * Creates a "back" menu option. When invoked, this option will return a {@code MenuResult.cancel()} result.
+     *
+     * @param isDefault
+     *            Indicates whether this option should be made the menu default.
+     */
+    public void addBackOption(boolean isDefault) {
+        addCharOption(INFO_MENU_OPTION_BACK_KEY.get(), INFO_MENU_OPTION_BACK.get(), MenuResult.<T> cancel());
+
+        if (isDefault) {
+            setDefault(INFO_MENU_OPTION_BACK_KEY.get(), MenuResult.<T> cancel());
+        }
+    }
+
+    /**
+     * Creates a "cancel" menu option. When invoked, this option will return a {@code MenuResult.cancel()} result.
+     *
+     * @param isDefault
+     *            Indicates whether this option should be made the menu default.
+     */
+    public void addCancelOption(boolean isDefault) {
+        addCharOption(INFO_MENU_OPTION_CANCEL_KEY.get(), INFO_MENU_OPTION_CANCEL.get(), MenuResult.<T> cancel());
+
+        if (isDefault) {
+            setDefault(INFO_MENU_OPTION_CANCEL_KEY.get(), MenuResult.<T> cancel());
+        }
+    }
+
+    /**
+     * Adds a menu choice to the menu which will have a single letter as its key.
+     *
+     * @param c
+     *            The single-letter message which will be used as the key for this option.
+     * @param description
+     *            The menu option description.
+     * @param callback
+     *            The call-back associated with this option.
+     */
+    public void addCharOption(LocalizableMessage c, LocalizableMessage description, MenuCallback<T> callback) {
+        charKeys.add(c);
+        charSynopsis.add(description);
+        charCallbacks.add(callback);
+    }
+
+    /**
+     * Adds a menu choice to the menu which will have a single letter as its key and which returns the provided result.
+     *
+     * @param c
+     *            The single-letter message which will be used as the key for this option.
+     * @param description
+     *            The menu option description.
+     * @param result
+     *            The menu result which should be returned by this menu choice.
+     */
+    public void addCharOption(LocalizableMessage c, LocalizableMessage description, MenuResult<T> result) {
+        addCharOption(c, description, new ResultCallback<T>(result));
+    }
+
+    /**
+     * Creates a "help" menu option which will use the provided help call-back to display help relating to the other
+     * menu options. When the help menu option is selected help will be displayed and then the user will be shown the
+     * menu again and prompted to enter a choice.
+     *
+     * @param callback
+     *            The help call-back.
+     */
+    public void addHelpOption(final HelpCallback callback) {
+        MenuCallback<T> wrapper = new MenuCallback<T>() {
+
+            public MenuResult<T> invoke(ConsoleApplication app) throws ClientException {
+                app.println();
+                callback.display(app);
+                return MenuResult.again();
+            }
+
+        };
+
+        addCharOption(INFO_MENU_OPTION_HELP_KEY.get(), INFO_MENU_OPTION_HELP.get(), wrapper);
+    }
+
+    /**
+     * Adds a menu choice to the menu which will have a numeric key.
+     *
+     * @param description
+     *            The menu option description.
+     * @param callback
+     *            The call-back associated with this option.
+     * @param extraFields
+     *            Any additional fields associated with this menu option.
+     * @return Returns the number associated with menu choice.
+     */
+    public int addNumberedOption(LocalizableMessage description, MenuCallback<T> callback,
+            LocalizableMessage... extraFields) {
+        List<LocalizableMessage> fields = new ArrayList<LocalizableMessage>();
+        fields.add(description);
+        if (extraFields != null) {
+            fields.addAll(Arrays.asList(extraFields));
+        }
+
+        numericFields.add(fields);
+        numericCallbacks.add(callback);
+
+        return numericCallbacks.size();
+    }
+
+    /**
+     * Adds a menu choice to the menu which will have a numeric key and which returns the provided result.
+     *
+     * @param description
+     *            The menu option description.
+     * @param result
+     *            The menu result which should be returned by this menu choice.
+     * @param extraFields
+     *            Any additional fields associated with this menu option.
+     * @return Returns the number associated with menu choice.
+     */
+    public int addNumberedOption(LocalizableMessage description, MenuResult<T> result,
+            LocalizableMessage... extraFields) {
+        return addNumberedOption(description, new ResultCallback<T>(result), extraFields);
+    }
+
+    /**
+     * Creates a "quit" menu option. When invoked, this option will return a {@code MenuResult.quit()} result.
+     */
+    public void addQuitOption() {
+        addCharOption(INFO_MENU_OPTION_QUIT_KEY.get(), INFO_MENU_OPTION_QUIT.get(), MenuResult.<T> quit());
+    }
+
+    /**
+     * Sets the flag which indicates whether or not the menu will permit multiple numeric options to be selected at
+     * once. Users specify multiple choices by separating them with a comma. The default is <code>false</code>.
+     *
+     * @param allowMultiSelect
+     *            Indicates whether or not the menu will permit multiple numeric options to be selected at once.
+     */
+    public void setAllowMultiSelect(boolean allowMultiSelect) {
+        this.allowMultiSelect = allowMultiSelect;
+    }
+
+    /**
+     * Sets the optional column headings. The column headings will be displayed above the menu options.
+     *
+     * @param headings
+     *            The optional column headings.
+     */
+    public void setColumnHeadings(LocalizableMessage... headings) {
+        this.columnHeadings.clear();
+        if (headings != null) {
+            this.columnHeadings.addAll(Arrays.asList(headings));
+        }
+    }
+
+    /**
+     * Sets the optional column widths. A value of zero indicates that the column should be expandable, a value of
+     * <code>null</code> indicates that the column should use its default width.
+     *
+     * @param widths
+     *            The optional column widths.
+     */
+    public void setColumnWidths(Integer... widths) {
+        this.columnWidths.clear();
+        if (widths != null) {
+            this.columnWidths.addAll(Arrays.asList(widths));
+        }
+    }
+
+    /**
+     * Sets the optional default action for this menu. The default action call-back will be invoked if the user does not
+     * specify an option and just presses enter.
+     *
+     * @param description
+     *            A short description of the default action.
+     * @param callback
+     *            The call-back associated with the default action.
+     */
+    public void setDefault(LocalizableMessage description, MenuCallback<T> callback) {
+        defaultCallback = callback;
+        defaultDescription = description;
+    }
+
+    /**
+     * Sets the optional default action for this menu. The default action call-back will be invoked if the user does not
+     * specify an option and just presses enter.
+     *
+     * @param description
+     *            A short description of the default action.
+     * @param result
+     *            The menu result which should be returned by default.
+     */
+    public void setDefault(LocalizableMessage description, MenuResult<T> result) {
+        setDefault(description, new ResultCallback<T>(result));
+    }
+
+    /**
+     * Sets the number of numeric options required to trigger multiple-column display. A negative value (the default)
+     * indicates that the numeric options will always be displayed in a single column. A value of 0 indicates that
+     * numeric options will always be displayed in multiple columns.
+     *
+     * @param threshold
+     *            The number of numeric options required to trigger multiple-column display.
+     */
+    public void setMultipleColumnThreshold(int threshold) {
+        this.threshold = threshold;
+    }
+
+    /**
+     * Sets the optional menu prompt. The prompt will be displayed above the menu. Menus do not have a prompt by
+     * default.
+     *
+     * @param prompt
+     *            The menu prompt, or <code>null</code> if there is not prompt.
+     */
+    public void setPrompt(LocalizableMessage prompt) {
+        this.prompt = prompt;
+    }
+
+    /**
+     * Sets the optional menu title. The title will be displayed above the menu prompt. Menus do not have a title by
+     * default.
+     *
+     * @param title
+     *            The menu title, or <code>null</code> if there is not title.
+     */
+    public void setTitle(LocalizableMessage title) {
+        this.title = title;
+    }
+
+    /**
+     * Creates a menu from this menu builder.
+     *
+     * @return Returns the new menu.
+     */
+    public Menu<T> toMenu() {
+        TableBuilder nbuilder = new TableBuilder();
+        Map<String, MenuCallback<T>> callbacks = new HashMap<String, MenuCallback<T>>();
+
+        // Determine whether multiple columns should be used for numeric
+        // options.
+        boolean useMultipleColumns = false;
+        if (threshold >= 0 && numericCallbacks.size() >= threshold) {
+            useMultipleColumns = true;
+        }
+
+        // Create optional column headers.
+        if (!columnHeadings.isEmpty()) {
+            nbuilder.appendHeading();
+            for (LocalizableMessage heading : columnHeadings) {
+                if (heading != null) {
+                    nbuilder.appendHeading(heading);
+                } else {
+                    nbuilder.appendHeading();
+                }
+            }
+
+            if (useMultipleColumns) {
+                nbuilder.appendHeading();
+                for (LocalizableMessage heading : columnHeadings) {
+                    if (heading != null) {
+                        nbuilder.appendHeading(heading);
+                    } else {
+                        nbuilder.appendHeading();
+                    }
+                }
+            }
+        }
+
+        // Add the numeric options first.
+        int sz = numericCallbacks.size();
+        int rows = sz;
+
+        if (useMultipleColumns) {
+            // Display in two columns the first column should contain half
+            // the options. If there are an odd number of columns then the
+            // first column should contain an additional option (e.g. if
+            // there are 23 options, the first column should contain 12
+            // options and the second column 11 options).
+            rows /= 2;
+            rows += sz % 2;
+        }
+
+        for (int i = 0, j = rows; i < rows; i++, j++) {
+            nbuilder.startRow();
+            nbuilder.appendCell(INFO_MENU_NUMERIC_OPTION.get(i + 1));
+
+            for (LocalizableMessage field : numericFields.get(i)) {
+                if (field != null) {
+                    nbuilder.appendCell(field);
+                } else {
+                    nbuilder.appendCell();
+                }
+            }
+
+            callbacks.put(String.valueOf(i + 1), numericCallbacks.get(i));
+
+            // Second column.
+            if (useMultipleColumns && (j < sz)) {
+                nbuilder.appendCell(INFO_MENU_NUMERIC_OPTION.get(j + 1));
+
+                for (LocalizableMessage field : numericFields.get(j)) {
+                    if (field != null) {
+                        nbuilder.appendCell(field);
+                    } else {
+                        nbuilder.appendCell();
+                    }
+                }
+
+                callbacks.put(String.valueOf(j + 1), numericCallbacks.get(j));
+            }
+        }
+
+        // Add the char options last.
+        TableBuilder cbuilder = new TableBuilder();
+        for (int i = 0; i < charCallbacks.size(); i++) {
+            char c = charKeys.get(i).charAt(0);
+            LocalizableMessage option = INFO_MENU_CHAR_OPTION.get(c);
+
+            cbuilder.startRow();
+            cbuilder.appendCell(option);
+            cbuilder.appendCell(charSynopsis.get(i));
+
+            callbacks.put(String.valueOf(c), charCallbacks.get(i));
+        }
+
+        // Configure the table printer.
+        TextTablePrinter printer = new TextTablePrinter(app.getErrorStream());
+
+        if (columnHeadings.isEmpty()) {
+            printer.setDisplayHeadings(false);
+        } else {
+            printer.setDisplayHeadings(true);
+            printer.setHeadingSeparatorStartColumn(1);
+        }
+
+        printer.setIndentWidth(4);
+        if (columnWidths.isEmpty()) {
+            printer.setColumnWidth(1, 0);
+            if (useMultipleColumns) {
+                printer.setColumnWidth(3, 0);
+            }
+        } else {
+            for (int i = 0; i < columnWidths.size(); i++) {
+                Integer j = columnWidths.get(i);
+                if (j != null) {
+                    // Skip the option key column.
+                    printer.setColumnWidth(i + 1, j);
+
+                    if (useMultipleColumns) {
+                        printer.setColumnWidth(i + 2 + columnWidths.size(), j);
+                    }
+                }
+            }
+        }
+
+        return new MenuImpl<T>(app, title, prompt, nbuilder, cbuilder, printer, callbacks, allowMultiSelect,
+                defaultCallback, defaultDescription, nMaxTries);
+    }
+
+    /**
+     * Sets the maximum number of tries that the user can provide an invalid value in the menu. -1 for unlimited tries
+     * (the default). If this limit is reached a ClientException will be thrown.
+     *
+     * @param nTries
+     *            the maximum number of tries.
+     */
+    public void setMaxTries(int nTries) {
+        nMaxTries = nTries;
+    }
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuCallback.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuCallback.java
new file mode 100644
index 0000000..a7bc29d
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuCallback.java
@@ -0,0 +1,51 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+
+/**
+ * A menu call-back which should be associated with each menu option.
+ * When an option is selected the call-back is invoked.
+ *
+ * @param <T>
+ *          The type of success result value(s) returned by the
+ *          call-back. Use <code>Void</code> if the call-backs do
+ *          not return any values.
+ */
+public interface MenuCallback<T> {
+
+   /**
+    * Invoke the menu call-back.
+    *
+    * @param app
+    *          The application console.
+    * @return Returns the result of invoking the menu call-back.
+    * @throws ClientException
+    *           If the menu call-back fails for some reason.
+    */
+    MenuResult<T> invoke(ConsoleApplication app) throws ClientException;
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuResult.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuResult.java
new file mode 100644
index 0000000..433b634
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/MenuResult.java
@@ -0,0 +1,261 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * The result of running a {@link Menu}. The result indicates to the
+ * application how it should proceed:
+ * <ul>
+ * <li>{@link #again()} - the menu should be displayed again. A good
+ * example of this is when a user chooses to view some help. Normally,
+ * after the help is displayed, the user is allowed to select another
+ * option
+ * <li>{@link #cancel()} - the user chose to cancel any task
+ * currently in progress and go back to the previous main menu if
+ * applicable
+ * <li>{@link #success()} - the user chose to apply any task
+ * currently in progress and go back to the previous menu if
+ * applicable. Any result values applicable to the chosen option can
+ * be retrieved using {@link #getValue()} or {@link #getValues()}
+ * <li>{@link #quit()} - the user chose to quit the application and
+ * cancel all outstanding tasks.
+ * </ul>
+ *
+ * @param <T>
+ *            The type of result value(s) contained in success results. Use <code>Void</code> if success results should
+ *            not contain values.
+ */
+public final class MenuResult<T> {
+
+    /**
+     * The type of result returned from the menu.
+     */
+    private static enum Type {
+        /**
+         * The user selected an option which did not return a result,
+         * so the menu should be displayed again.
+         */
+        AGAIN,
+
+        /**
+         * The user did not select an option and instead
+         * chose to cancel the current task.
+         */
+        CANCEL,
+
+        /**
+         * The user did not select an option and instead
+         * chose to quit the entire application.
+         */
+        QUIT,
+
+        /**
+         * The user selected an option which succeeded
+         * and returned one or more result values.
+         */
+        SUCCESS
+    }
+
+    /**
+     * Creates a new menu result indicating that the menu should be displayed again. A good example of this is when a
+     * user chooses to view some help. Normally, after the help is displayed, the user is allowed to select another
+     * option.
+     *
+     * @param <T>
+     *            The type of result value(s) contained in success results. Use <code>Void</code> if success results
+     *            should not contain values.
+     * @return Returns a new menu result indicating that the menu should be displayed again.
+     */
+    public static <T> MenuResult<T> again() {
+        return new MenuResult<T>(Type.AGAIN, Collections.<T> emptyList());
+    }
+
+    /**
+     * Creates a new menu result indicating that the user chose to cancel any task currently in progress and go back to
+     * the previous main menu if applicable.
+     *
+     * @param <T>
+     *            The type of result value(s) contained in success results. Use <code>Void</code> if success results
+     *            should not contain values.
+     * @return Returns a new menu result indicating that the user chose to cancel any task currently in progress and go
+     *         back to the previous main menu if applicable.
+     */
+    public static <T> MenuResult<T> cancel() {
+        return new MenuResult<T>(Type.CANCEL, Collections.<T> emptyList());
+    }
+
+    /**
+     * Creates a new menu result indicating that the user chose to quit the application and cancel all outstanding
+     * tasks.
+     *
+     * @param <T>
+     *            The type of result value(s) contained in success results. Use <code>Void</code> if success results
+     *            should not contain values.
+     * @return Returns a new menu result indicating that the user chose to quit the application and cancel all
+     *         outstanding tasks.
+     */
+    public static <T> MenuResult<T> quit() {
+        return new MenuResult<T>(Type.QUIT, Collections.<T> emptyList());
+    }
+
+    /**
+     * Creates a new menu result indicating that the user chose to apply any task currently in progress and go back to
+     * the previous menu if applicable. The menu result will not contain any result values.
+     *
+     * @param <T>
+     *            The type of result value(s) contained in success results. Use <code>Void</code> if success results
+     *            should not contain values.
+     * @return Returns a new menu result indicating that the user chose to apply any task currently in progress and go
+     *         back to the previous menu if applicable.The menu result will not contain any result values.
+     */
+    public static <T> MenuResult<T> success() {
+        return success(Collections.<T> emptySet());
+    }
+
+    /**
+     * Creates a new menu result indicating that the user chose to apply any task currently in progress and go back to
+     * the previous menu if applicable. The menu result will contain the provided values, which can be retrieved using
+     * {@link #getValue()} or {@link #getValues()}.
+     *
+     * @param <T>
+     *            The type of the result values.
+     * @param values
+     *            The result values.
+     * @return Returns a new menu result indicating that the user chose to apply any task currently in progress and go
+     *         back to the previous menu if applicable. The menu result will contain the provided values, which can be
+     *         retrieved using {@link #getValue()} or {@link #getValues()}.
+     */
+    public static <T> MenuResult<T> success(Collection<T> values) {
+        return new MenuResult<T>(Type.SUCCESS, new ArrayList<T>(values));
+    }
+
+    /**
+     * Creates a new menu result indicating that the user chose to apply any task currently in progress and go back to
+     * the previous menu if applicable. The menu result will contain the provided value, which can be retrieved using
+     * {@link #getValue()} or {@link #getValues()}.
+     *
+     * @param <T>
+     *            The type of the result value.
+     * @param value
+     *            The result value.
+     * @return Returns a new menu result indicating that the user chose to apply any task currently in progress and go
+     *         back to the previous menu if applicable. The menu result will contain the provided value, which can be
+     *         retrieved using {@link #getValue()} or {@link #getValues()}.
+     */
+    public static <T> MenuResult<T> success(T value) {
+        return success(Collections.singleton(value));
+    }
+
+    // The type of result returned from the menu.
+    private final Type type;
+
+    // The menu result value(s).
+    private final Collection<T> values;
+
+    // Private constructor.
+    private MenuResult(Type type, Collection<T> values) {
+        this.type = type;
+        this.values = values;
+    }
+
+    /**
+     * Gets the menu result value if this is a menu result indicating success.
+     *
+     * @return Returns the menu result value, or <code>null</code> if there was no result value or if this is not a
+     *         success menu result.
+     * @see #isSuccess()
+     */
+    public T getValue() {
+        if (values.isEmpty()) {
+            return null;
+        } else {
+            return values.iterator().next();
+        }
+    }
+
+    /**
+     * Gets the menu result values if this is a menu result indicating success.
+     *
+     * @return Returns the menu result values, which may be empty if there were no result values or if this is not a
+     *         success menu result.
+     * @see #isSuccess()
+     */
+    public Collection<T> getValues() {
+        return new ArrayList<T>(values);
+    }
+
+    /**
+     * Determines if this menu result indicates that the menu should be displayed again. A good example of this is when
+     * a user chooses to view some help. Normally, after the help is displayed, the user is allowed to select another
+     * option.
+     *
+     * @return Returns <code>true</code> if this menu result indicates that the menu should be displayed again.
+     */
+    public boolean isAgain() {
+        return type == Type.AGAIN;
+    }
+
+    /**
+     * Determines if this menu result indicates that the user chose to cancel any task currently in progress and go back
+     * to the previous main menu if applicable.
+     *
+     * @return Returns <code>true</code> if this menu result indicates that the user chose to cancel any task currently
+     *         in progress and go back to the previous main menu if applicable.
+     */
+    public boolean isCancel() {
+        return type == Type.CANCEL;
+    }
+
+    /**
+     * Determines if this menu result indicates that the user chose to quit the application and cancel all outstanding
+     * tasks.
+     *
+     * @return Returns <code>true</code> if this menu result indicates that the user chose to quit the application and
+     *         cancel all outstanding tasks.
+     */
+    public boolean isQuit() {
+        return type == Type.QUIT;
+    }
+
+    /**
+     * Determines if this menu result indicates that the user chose to apply any task currently in progress and go back
+     * to the previous menu if applicable. Any result values can be retrieved using the {@link #getValue()} or
+     * {@link #getValues()} methods.
+     *
+     * @return Returns <code>true</code> if this menu result indicates that the user chose to apply any task currently
+     *         in progress and go back to the previous menu if applicable.
+     * @see #getValue()
+     * @see #getValues()
+     */
+    public boolean isSuccess() {
+        return type == Type.SUCCESS;
+    }
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/TableBuilder.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TableBuilder.java
new file mode 100644
index 0000000..06692c7
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TableBuilder.java
@@ -0,0 +1,328 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import org.forgerock.i18n.LocalizableMessage;
+
+/**
+ * A class which can be used to construct tables of information to be displayed in a terminal.
+ * Once built the table can be output using a {@link TableSerializer}.
+ */
+public final class TableBuilder {
+
+    // The current column number in the current row where 0 represents
+    // the left-most column in the table.
+    private int column = 0;
+
+    // The current with of each column.
+    private List<Integer> columnWidths = new ArrayList<Integer>();
+
+    // The list of column headings.
+    private List<LocalizableMessage> header = new ArrayList<LocalizableMessage>();
+
+    // The current number of rows in the table.
+    private int height = 0;
+
+    // The list of table rows.
+    private List<List<String>> rows = new ArrayList<List<String>>();
+
+    // The linked list of sort keys comparators.
+    private List<Comparator<String>> sortComparators = new ArrayList<Comparator<String>>();
+
+    // The linked list of sort keys.
+    private List<Integer> sortKeys = new ArrayList<Integer>();
+
+    // The current number of columns in the table.
+    private int width = 0;
+
+    /**
+     * Creates a new table printer.
+     */
+    public TableBuilder() {
+        // No implementation required.
+    }
+
+    /**
+     * Adds a table sort key. The table will be sorted according to the case-insensitive string ordering of the cells in
+     * the specified column.
+     *
+     * @param column
+     *            The column which will be used as a sort key.
+     */
+    public void addSortKey(int column) {
+        addSortKey(column, String.CASE_INSENSITIVE_ORDER);
+    }
+
+    /**
+     * Adds a table sort key. The table will be sorted according to the provided string comparator.
+     *
+     * @param column
+     *            The column which will be used as a sort key.
+     * @param comparator
+     *            The string comparator.
+     */
+    public void addSortKey(int column, Comparator<String> comparator) {
+        sortKeys.add(column);
+        sortComparators.add(comparator);
+    }
+
+    /**
+     * Appends a new blank cell to the current row.
+     */
+    public void appendCell() {
+        appendCell("");
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided boolean value.
+     *
+     * @param value
+     *            The boolean value.
+     */
+    public void appendCell(boolean value) {
+        appendCell(String.valueOf(value));
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided byte value.
+     *
+     * @param value
+     *            The byte value.
+     */
+    public void appendCell(byte value) {
+        appendCell(String.valueOf(value));
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided char value.
+     *
+     * @param value
+     *            The char value.
+     */
+    public void appendCell(char value) {
+        appendCell(String.valueOf(value));
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided double value.
+     *
+     * @param value
+     *            The double value.
+     */
+    public void appendCell(double value) {
+        appendCell(String.valueOf(value));
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided float value.
+     *
+     * @param value
+     *            The float value.
+     */
+    public void appendCell(float value) {
+        appendCell(String.valueOf(value));
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided integer value.
+     *
+     * @param value
+     *            The boolean value.
+     */
+    public void appendCell(int value) {
+        appendCell(String.valueOf(value));
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided long value.
+     *
+     * @param value
+     *            The long value.
+     */
+    public void appendCell(long value) {
+        appendCell(String.valueOf(value));
+    }
+
+    /**
+     * Appends a new cell to the current row containing the provided object value.
+     *
+     * @param value
+     *            The object value.
+     */
+    public void appendCell(Object value) {
+        // Make sure that the first row has been created.
+        if (height == 0) {
+            startRow();
+        }
+
+        // Create the cell.
+        String s = String.valueOf(value);
+        rows.get(height - 1).add(s);
+        column++;
+
+        // Update statistics.
+        if (column > width) {
+            width = column;
+            columnWidths.add(s.length());
+        } else if (columnWidths.get(column - 1) < s.length()) {
+            columnWidths.set(column - 1, s.length());
+        }
+    }
+
+    /**
+     * Appends a new blank column heading to the header row.
+     */
+    public void appendHeading() {
+        appendHeading(LocalizableMessage.EMPTY);
+    }
+
+    /**
+     * Appends a new column heading to the header row.
+     *
+     * @param value
+     *            The column heading value.
+     */
+    public void appendHeading(LocalizableMessage value) {
+        header.add(value);
+
+        // Update statistics.
+        if (header.size() > width) {
+            width = header.size();
+            columnWidths.add(value.length());
+        } else if (columnWidths.get(header.size() - 1) < value.length()) {
+            columnWidths.set(header.size() - 1, value.length());
+        }
+    }
+
+    /**
+     * Gets the width of the current row.
+     *
+     * @return Returns the width of the current row.
+     */
+    public int getRowWidth() {
+        return column;
+    }
+
+    /**
+     * Gets the number of rows in table.
+     *
+     * @return Returns the number of rows in table.
+     */
+    public int getTableHeight() {
+        return height;
+    }
+
+    /**
+     * Gets the number of columns in table.
+     *
+     * @return Returns the number of columns in table.
+     */
+    public int getTableWidth() {
+        return width;
+    }
+
+    /**
+     * Prints the table in its current state using the provided table printer.
+     *
+     * @param printer
+     *            The table printer.
+     */
+    public void print(TablePrinter printer) {
+        // Create a new printer instance.
+        TableSerializer serializer = printer.getSerializer();
+
+        // First sort the table.
+        List<List<String>> sortedRows = new ArrayList<List<String>>(rows);
+
+        Comparator<List<String>> comparator = new Comparator<List<String>>() {
+
+            public int compare(List<String> row1, List<String> row2) {
+                for (int i = 0; i < sortKeys.size(); i++) {
+                    String cell1 = row1.get(sortKeys.get(i));
+                    String cell2 = row2.get(sortKeys.get(i));
+
+                    int rc = sortComparators.get(i).compare(cell1, cell2);
+                    if (rc != 0) {
+                        return rc;
+                    }
+                }
+
+                // Both rows are equal.
+                return 0;
+            }
+
+        };
+
+        Collections.sort(sortedRows, comparator);
+
+        // Now output the table.
+        serializer.startTable(height, width);
+        for (int i = 0; i < width; i++) {
+            serializer.addColumn(columnWidths.get(i));
+        }
+
+        // Column headings.
+        serializer.startHeader();
+        for (LocalizableMessage s : header) {
+            serializer.addHeading(s.toString());
+        }
+        serializer.endHeader();
+
+        // Table contents.
+        serializer.startContent();
+        for (List<String> row : sortedRows) {
+            serializer.startRow();
+
+            // Print each cell in the row, padding missing trailing cells.
+            for (int i = 0; i < width; i++) {
+                if (i < row.size()) {
+                    serializer.addCell(row.get(i));
+                } else {
+                    serializer.addCell("");
+                }
+            }
+
+            serializer.endRow();
+        }
+        serializer.endContent();
+        serializer.endTable();
+    }
+
+    /**
+     * Appends a new row to the table.
+     */
+    public void startRow() {
+        rows.add(new ArrayList<String>());
+        height++;
+        column = 0;
+    }
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/TablePrinter.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TablePrinter.java
new file mode 100644
index 0000000..b54cd4c
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TablePrinter.java
@@ -0,0 +1,50 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+/**
+ * An interface for incrementally configuring a table serializer. Once
+ * configured, the table printer can be used to create a new
+ * {@link TableSerializer} instance using the {@link #getSerializer()}
+ * method.
+ */
+public abstract class TablePrinter {
+
+    /**
+     * Creates a new abstract table printer.
+     */
+    protected TablePrinter() {
+        // No implementation required.
+    }
+
+    /**
+     * Creates a new table serializer based on the configuration of this table printer.
+     *
+     * @return Returns a new table serializer based on the configuration of this table printer.
+     */
+    protected abstract TableSerializer getSerializer();
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/TableSerializer.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TableSerializer.java
new file mode 100644
index 0000000..3846725
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TableSerializer.java
@@ -0,0 +1,134 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+/**
+ * An interface for serializing tables.
+ * <p>
+ * The default implementation for each method is to do nothing.
+ * Implementations must override methods as required.
+ */
+public abstract class TableSerializer {
+
+    /**
+     * Create a new table serializer.
+     */
+    protected TableSerializer() {
+        // No implementation required.
+    }
+
+    /**
+     * Prints a table cell.
+     *
+     * @param s
+     *            The cell contents.
+     */
+    public void addCell(String s) {
+        // Default implementation.
+    }
+
+    /**
+     * Defines a column in the table.
+     *
+     * @param width
+     *            The width of the column in characters.
+     */
+    public void addColumn(int width) {
+        // Default implementation.
+    }
+
+    /**
+     * Prints a column heading.
+     *
+     * @param s
+     *            The column heading.
+     */
+    public void addHeading(String s) {
+        // Default implementation.
+    }
+
+    /**
+     * Finish printing the table contents.
+     */
+    public void endContent() {
+        // Default implementation.
+    }
+
+    /**
+     * Finish printing the column headings.
+     */
+    public void endHeader() {
+        // Default implementation.
+    }
+
+    /**
+     * Finish printing the current row of the table.
+     */
+    public void endRow() {
+        // Default implementation.
+    }
+
+    /**
+     * Finish printing the table.
+     */
+    public void endTable() {
+        // Default implementation.
+    }
+
+    /**
+     * Prepare to start printing the table contents.
+     */
+    public void startContent() {
+        // Default implementation.
+    }
+
+    /**
+     * Prepare to start printing the column headings.
+     */
+    public void startHeader() {
+        // Default implementation.
+    }
+
+    /**
+     * Prepare to start printing a new row of the table.
+     */
+    public void startRow() {
+        // Default implementation.
+    }
+
+    /**
+     * Start a new table having the specified number of rows and columns.
+     *
+     * @param height
+     *            The number of rows in the table.
+     * @param width
+     *            The number of columns in the table.
+     */
+    public void startTable(int height, int width) {
+        // Default implementation.
+    }
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/TextTablePrinter.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TextTablePrinter.java
new file mode 100644
index 0000000..ca8906c
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/TextTablePrinter.java
@@ -0,0 +1,497 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2007-2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+import static com.forgerock.opendj.cli.Utils.MAX_LINE_WIDTH;
+import java.io.BufferedWriter;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An interface for creating a text based table.
+ * Tables have configurable column widths, padding, and column separators.
+ */
+public final class TextTablePrinter extends TablePrinter {
+
+    /**
+     * Table serializer implementation.
+     */
+    private final class Serializer extends TableSerializer {
+
+        // The current column being output.
+        private int column = 0;
+
+        /*The real column widths taking into account size constraints but
+         not including padding or separators.*/
+        private final List<Integer> columnWidths = new ArrayList<Integer>();
+
+        // The cells in the current row.
+        private final List<String> currentRow = new ArrayList<String>();
+
+        // Width of the table in columns.
+        private int totalColumns = 0;
+
+        // The padding to use for indenting the table.
+        private final String indentPadding;
+
+        // Private constructor.
+        private Serializer() {
+            // Compute the indentation padding.
+            final StringBuilder builder = new StringBuilder();
+            for (int i = 0; i < indentWidth; i++) {
+                builder.append(' ');
+            }
+            this.indentPadding = builder.toString();
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void addCell(String s) {
+            currentRow.add(s);
+            column++;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void addColumn(int width) {
+            columnWidths.add(width);
+            totalColumns++;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void addHeading(String s) {
+            if (displayHeadings) {
+                addCell(s);
+            }
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void endHeader() {
+            if (displayHeadings) {
+                endRow();
+
+                // Print the header separator.
+                final StringBuilder builder = new StringBuilder(indentPadding);
+                for (int i = 0; i < totalColumns; i++) {
+                    int width = columnWidths.get(i);
+                    if (totalColumns > 1) {
+                        if (i == 0 || i == (totalColumns - 1)) {
+                            // Only one lot of padding for first and last columns.
+                            width += padding;
+                        } else {
+                            width += padding * 2;
+                        }
+                    }
+
+                    for (int j = 0; j < width; j++) {
+                        if (headingSeparatorStartColumn > 0) {
+                            if (i < headingSeparatorStartColumn) {
+                                builder.append(' ');
+                            } else if (i == headingSeparatorStartColumn && j < padding) {
+                                builder.append(' ');
+                            } else {
+                                builder.append(headingSeparator);
+                            }
+                        } else {
+                            builder.append(headingSeparator);
+                        }
+                    }
+
+                    if ((i >= headingSeparatorStartColumn) && i < (totalColumns - 1)) {
+                        builder.append(columnSeparator);
+                    }
+                }
+                writer.println(builder.toString());
+            }
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void endRow() {
+            boolean isRemainingText;
+            do {
+                StringBuilder builder = new StringBuilder(indentPadding);
+                isRemainingText = false;
+                for (int i = 0; i < currentRow.size(); i++) {
+                    int width = columnWidths.get(i);
+                    String contents = currentRow.get(i);
+
+                    // Determine what parts of contents can be displayed on this line.
+                    String head;
+                    String tail = null;
+
+                    if (contents == null) {
+                        // This cell has been displayed fully.
+                        head = "";
+                    } else if (contents.length() > width) {
+                        // We're going to have to split the cell on next word boundary.
+                        int endIndex = contents.lastIndexOf(' ', width);
+                        if (endIndex == -1) {
+                            endIndex = width;
+                            head = contents.substring(0, endIndex);
+                            tail = contents.substring(endIndex);
+
+                        } else {
+                            head = contents.substring(0, endIndex);
+                            tail = contents.substring(endIndex + 1);
+                        }
+                    } else {
+                        // The contents fits ok.
+                        head = contents;
+                    }
+
+                    // Add this cell's contents to the current line.
+                    if (i > 0) {
+                        // Add right padding for previous cell.
+                        for (int j = 0; j < padding; j++) {
+                            builder.append(' ');
+                        }
+
+                        // Add separator.
+                        builder.append(columnSeparator);
+
+                        // Add left padding for this cell.
+                        for (int j = 0; j < padding; j++) {
+                            builder.append(' ');
+                        }
+                    }
+
+                    // Add cell contents.
+                    builder.append(head);
+
+                    // Now pad with extra space to make up the width.
+                    // Only if it's not the last cell (see issue #3210)
+                    if (i != currentRow.size() - 1) {
+                        for (int j = head.length(); j < width; j++) {
+                            builder.append(' ');
+                        }
+                    }
+
+                    // Update the row contents.
+                    currentRow.set(i, tail);
+                    if (tail != null) {
+                        isRemainingText = true;
+                    }
+                }
+
+                // Output the line.
+                writer.println(builder.toString());
+
+            } while (isRemainingText);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void endTable() {
+            writer.flush();
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void startHeader() {
+            determineColumnWidths();
+
+            column = 0;
+            currentRow.clear();
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void startRow() {
+            column = 0;
+            currentRow.clear();
+        }
+
+        // We need to calculate the effective width of each column.
+        private void determineColumnWidths() {
+            // First calculate the minimum width so that we know how much
+            // expandable columns can expand.
+            int minWidth = indentWidth;
+            int expandableColumnSize = 0;
+
+            for (int i = 0; i < totalColumns; i++) {
+                int actualSize = columnWidths.get(i);
+
+                if (fixedColumns.containsKey(i)) {
+                    int requestedSize = fixedColumns.get(i);
+
+                    if (requestedSize == 0) {
+                        expandableColumnSize += actualSize;
+                    } else {
+                        columnWidths.set(i, requestedSize);
+                        minWidth += requestedSize;
+                    }
+                } else {
+                    minWidth += actualSize;
+                }
+
+                // Must also include padding and separators.
+                if (i > 0) {
+                    minWidth += padding * 2 + columnSeparator.length();
+                }
+            }
+
+            if (minWidth > totalWidth) {
+                // The table is too big: leave expandable columns at their
+                // requested width, as there's not much else that can be done.
+            } else {
+                int available = totalWidth - minWidth;
+
+                if (expandableColumnSize > available) {
+                    // Only modify column sizes if necessary.
+                    for (int i = 0; i < totalColumns; i++) {
+                        int actualSize = columnWidths.get(i);
+
+                        if (fixedColumns.containsKey(i)) {
+                            int requestedSize = fixedColumns.get(i);
+                            if (requestedSize == 0) {
+                                // Calculate size based on requested actual size as a
+                                // proportion of the total.
+                                requestedSize = ((actualSize * available) / expandableColumnSize);
+                                columnWidths.set(i, requestedSize);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * The default string which should be used to separate one column from the next (not including padding).
+     */
+    private static final String DEFAULT_COLUMN_SEPARATOR = "";
+
+    /**
+     * The default character which should be used to separate the table heading row from the rows beneath.
+     */
+    private static final char DEFAULT_HEADING_SEPARATOR = '-';
+
+    /**
+     * The default padding which will be used to separate a cell's contents from its adjacent column separators.
+     */
+    private static final int DEFAULT_PADDING = 1;
+
+    // The string which should be used to separate one column
+    // from the next (not including padding).
+    private String columnSeparator = DEFAULT_COLUMN_SEPARATOR;
+
+    // Indicates whether or not the headings should be output.
+    private boolean displayHeadings = true;
+
+    // Table indicating whether or not a column is fixed width.
+    private final Map<Integer, Integer> fixedColumns = new HashMap<Integer, Integer>();
+
+    // The number of characters the table should be indented.
+    private int indentWidth = 0;
+
+    // The character which should be used to separate the table
+    // heading row from the rows beneath.
+    private char headingSeparator = DEFAULT_HEADING_SEPARATOR;
+
+    // The column where the heading separator should begin.
+    private int headingSeparatorStartColumn = 0;
+
+    // The padding which will be used to separate a cell's
+    // contents from its adjacent column separators.
+    private int padding = DEFAULT_PADDING;
+
+    // Total permitted width for the table which expandable columns
+    // can use up.
+    private int totalWidth = MAX_LINE_WIDTH;
+
+    // The output destination.
+    private PrintWriter writer = null;
+
+    /**
+     * Creates a new text table printer for the specified output stream. The text table printer will have the following
+     * initial settings:
+     * <ul>
+     * <li>headings will be displayed
+     * <li>no separators between columns
+     * <li>columns are padded by one character
+     * </ul>
+     *
+     * @param stream
+     *            The stream to output tables to.
+     */
+    public TextTablePrinter(OutputStream stream) {
+        this(new BufferedWriter(new OutputStreamWriter(stream)));
+    }
+
+    /**
+     * Creates a new text table printer for the specified writer. The text table printer will have the following initial
+     * settings:
+     * <ul>
+     * <li>headings will be displayed
+     * <li>no separators between columns
+     * <li>columns are padded by one character
+     * </ul>
+     *
+     * @param writer
+     *            The writer to output tables to.
+     */
+    public TextTablePrinter(Writer writer) {
+        this.writer = new PrintWriter(writer);
+    }
+
+    /**
+     * Sets the column separator which should be used to separate one column from the next (not including padding).
+     *
+     * @param columnSeparator
+     *            The column separator.
+     */
+    public void setColumnSeparator(String columnSeparator) {
+        this.columnSeparator = columnSeparator;
+    }
+
+    /**
+     * Set the maximum width for a column. If a cell is too big to fit in its column then it will be wrapped.
+     *
+     * @param column
+     *            The column to make fixed width (0 is the first column).
+     * @param width
+     *            The width of the column (this should not include column separators or padding), or <code>0</code> to
+     *            indicate that this column should be expandable.
+     * @throws IllegalArgumentException
+     *             If column is less than 0.
+     */
+    public void setColumnWidth(int column, int width) {
+        if (column < 0) {
+            throw new IllegalArgumentException("Negative column " + column);
+        }
+
+        if (width < 0) {
+            throw new IllegalArgumentException("Negative width " + width);
+        }
+
+        fixedColumns.put(column, width);
+    }
+
+    /**
+     * Specify whether the column headings should be displayed or not.
+     *
+     * @param displayHeadings
+     *            <code>true</code> if column headings should be displayed.
+     */
+    public void setDisplayHeadings(boolean displayHeadings) {
+        this.displayHeadings = displayHeadings;
+    }
+
+    /**
+     * Sets the heading separator which should be used to separate the table heading row from the rows beneath.
+     *
+     * @param headingSeparator
+     *            The heading separator.
+     */
+    public void setHeadingSeparator(char headingSeparator) {
+        this.headingSeparator = headingSeparator;
+    }
+
+    /**
+     * Sets the heading separator start column. The heading separator will only be display in the specified column and
+     * all subsequent columns. Usually this should be left at zero (the default) but sometimes it useful to indent the
+     * heading separate in order to provide additional emphasis (for example in menus).
+     *
+     * @param startColumn
+     *            The heading separator start column.
+     */
+    public void setHeadingSeparatorStartColumn(int startColumn) {
+        if (startColumn < 0) {
+            throw new IllegalArgumentException("Negative start column " + startColumn);
+        }
+        this.headingSeparatorStartColumn = startColumn;
+    }
+
+    /**
+     * Sets the amount of characters that the table should be indented. By default the table is not indented.
+     *
+     * @param indentWidth
+     *            The number of characters the table should be indented.
+     * @throws IllegalArgumentException
+     *             If indentWidth is less than 0.
+     */
+    public void setIndentWidth(int indentWidth) {
+        if (indentWidth < 0) {
+            throw new IllegalArgumentException("Negative indentation width " + indentWidth);
+        }
+
+        this.indentWidth = indentWidth;
+    }
+
+    /**
+     * Sets the padding which will be used to separate a cell's contents from its adjacent column separators.
+     *
+     * @param padding
+     *            The padding.
+     */
+    public void setPadding(int padding) {
+        this.padding = padding;
+    }
+
+    /**
+     * Sets the total permitted width for the table which expandable columns can use up.
+     *
+     * @param totalWidth
+     *            The total width.
+     */
+    public void setTotalWidth(int totalWidth) {
+        this.totalWidth = totalWidth;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected TableSerializer getSerializer() {
+        return new Serializer();
+    }
+}
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java
index 0c48ba0..0ee1a38 100644
--- a/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java
@@ -26,7 +26,12 @@
  */
 package com.forgerock.opendj.cli;
 
-import static com.forgerock.opendj.cli.CliMessages.*;
+import static com.forgerock.opendj.cli.CliMessages.ERR_INCOMPATIBLE_JAVA_VERSION;
+import static com.forgerock.opendj.cli.CliMessages.INFO_TIME_IN_DAYS_HOURS_MINUTES_SECONDS;
+import static com.forgerock.opendj.cli.CliMessages.INFO_TIME_IN_HOURS_MINUTES_SECONDS;
+import static com.forgerock.opendj.cli.CliMessages.INFO_TIME_IN_MINUTES_SECONDS;
+import static com.forgerock.opendj.cli.CliMessages.INFO_TIME_IN_SECONDS;
+import com.forgerock.opendj.util.OperatingSystem;
 import static com.forgerock.opendj.util.StaticUtils.EOL;
 
 import java.io.File;
@@ -34,7 +39,10 @@
 import java.io.IOException;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
 import java.util.StringTokenizer;
+import java.util.TimeZone;
 
 import org.forgerock.i18n.LocalizableMessage;
 
@@ -42,10 +50,32 @@
  * This class provides utility functions for all the client side tools.
  */
 final public class Utils {
+
     /** Platform appropriate line separator. */
     static public final String LINE_SEPARATOR = System.getProperty("line.separator");
 
     /**
+     * The value used to display arguments that must be obfuscated (such as passwords). This does not require
+     * localization (since the output of command builder by its nature is not localized).
+     */
+    public final static String OBFUSCATED_VALUE = "******";
+
+    /**
+     * The date format string that will be used to construct and parse dates represented using generalized time. It is
+     * assumed that the provided date formatter will be set to UTC.
+     */
+    public static final String DATE_FORMAT_LOCAL_TIME = "dd/MMM/yyyy:HH:mm:ss Z";
+
+    private static final String COMMENT_SHELL_UNIX = "# ";
+    private static final String COMMENT_BATCH_WINDOWS = "rem ";
+
+    /**
+     * The String used to write comments in a shell (or batch) script.
+     */
+    public static final String SHELL_COMMENT_SEPARATOR = OperatingSystem.isWindows() ? COMMENT_BATCH_WINDOWS
+            : COMMENT_SHELL_UNIX;
+
+    /**
      * The column at which to wrap long lines of output in the command-line
      * tools.
      */
@@ -65,6 +95,22 @@
     }
 
     /**
+     * Formats a Date to String representation in "dd/MMM/yyyy:HH:mm:ss Z".
+     *
+     * @param date
+     *            to format; null if <code>date</code> is null
+     * @return string representation of the date
+     */
+    public String formatDateTimeStringForEquivalentCommand(Date date) {
+        String timeStr = null;
+        if (date != null) {
+            SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT_LOCAL_TIME);
+            dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+            timeStr = dateFormat.format(date);
+        }
+        return timeStr;
+    }
+    /**
      * Filters the provided value to ensure that it is appropriate for use as an
      * exit code. Exit code values are generally only allowed to be between 0
      * and 255, so any value outside of this range will be converted to 255,
diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/ValidationCallback.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/ValidationCallback.java
new file mode 100644
index 0000000..da88811
--- /dev/null
+++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/ValidationCallback.java
@@ -0,0 +1,53 @@
+/*
+ * CDDL HEADER START
+ *
+ * The contents of this file are subject to the terms of the
+ * Common Development and Distribution License, Version 1.0 only
+ * (the "License").  You may not use this file except in compliance
+ * with the License.
+ *
+ * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
+ * or http://forgerock.org/license/CDDLv1.0.html.
+ * See the License for the specific language governing permissions
+ * and limitations under the License.
+ *
+ * When distributing Covered Code, include this CDDL HEADER in each
+ * file and include the License file at legal-notices/CDDLv1_0.txt.
+ * If applicable, add the following below this CDDL HEADER, with the
+ * fields enclosed by brackets "[]" replaced with your own identifying
+ * information:
+ *      Portions Copyright [yyyy] [name of copyright owner]
+ *
+ * CDDL HEADER END
+ *
+ *
+ *      Copyright 2008 Sun Microsystems, Inc.
+ *      Portions Copyright 2014 ForgeRock AS
+ */
+package com.forgerock.opendj.cli;
+
+/**
+ * An interface for validating user input.
+ *
+ * @param <T>
+ *            The type of the decoded input.
+ */
+public interface ValidationCallback<T> {
+
+    /**
+     * Validates and decodes the user-provided input. Implementations must validate
+     * <code>input</code> and return the decoded value if the input is acceptable.
+     * If the input is unacceptable, implementations must return
+     * <code>null</code> and output a user friendly error message to the provided
+     * application console.
+     *
+     * @param app
+     *            The console application.
+     * @param input
+     *            The user input to be validated.
+     * @return Returns the decoded input if the input is valid, or <code>null</code> if it is not.
+     * @throws ClientException
+     *             If an unexpected error occurred which prevented validation.
+     */
+    T validate(ConsoleApplication app, String input) throws ClientException;
+}
diff --git a/opendj-cli/src/main/resources/com/forgerock/opendj/cli/cli.properties b/opendj-cli/src/main/resources/com/forgerock/opendj/cli/cli.properties
index 9656f31..7fe48b8 100755
--- a/opendj-cli/src/main/resources/com/forgerock/opendj/cli/cli.properties
+++ b/opendj-cli/src/main/resources/com/forgerock/opendj/cli/cli.properties
@@ -715,3 +715,24 @@
 INFO_DESCRIPTION_CONNECTION_TIMEOUT=Maximum length of time (in \
  milliseconds) that can be taken to establish a connection.  Use '0' to \
  specify no time out
+ERR_MENU_BAD_CHOICE_MULTI=Invalid response. Please enter one or \
+more valid menu options
+ERR_MENU_BAD_CHOICE_SINGLE=Invalid response. Please enter a valid \
+menu option
+ERR_MENU_BAD_CHOICE_MULTI_DUPE=The option "%s" was specified \
+more than once. Please enter one or more valid menu options
+INFO_MENU_PROMPT_MULTI_DEFAULT=Enter one or more choices separated by commas [%s]:
+INFO_MENU_PROMPT_MULTI=Enter one or more choices separated by commas:
+INFO_MENU_PROMPT_SINGLE_DEFAULT=Enter choice [%s]:
+INFO_MENU_PROMPT_SINGLE=Enter choice:
+INFO_MENU_OPTION_HELP=help
+INFO_MENU_OPTION_HELP_KEY=?
+INFO_MENU_OPTION_CANCEL=cancel
+INFO_MENU_OPTION_CANCEL_KEY=c
+INFO_MENU_OPTION_QUIT=quit
+INFO_MENU_OPTION_QUIT_KEY=q
+INFO_MENU_NUMERIC_OPTION=%d)
+INFO_MENU_CHAR_OPTION=%c)
+INFO_MENU_OPTION_BACK=back
+INFO_MENU_OPTION_BACK_KEY=b
+ERR_TRIES_LIMIT_REACHED=Input tries limit reached (%d)
diff --git a/opendj-cli/src/test/java/com/forgerock/opendj/cli/ConsoleApplicationTestCase.java b/opendj-cli/src/test/java/com/forgerock/opendj/cli/ConsoleApplicationTestCase.java
index f0aa277..4b291a6 100644
--- a/opendj-cli/src/test/java/com/forgerock/opendj/cli/ConsoleApplicationTestCase.java
+++ b/opendj-cli/src/test/java/com/forgerock/opendj/cli/ConsoleApplicationTestCase.java
@@ -41,6 +41,10 @@
  */
 public class ConsoleApplicationTestCase extends CliTestCase {
 
+    final LocalizableMessage msg = LocalizableMessage.raw("Language is the source of misunderstandings.");
+    final LocalizableMessage msg2 = LocalizableMessage
+            .raw("If somebody wants a sheep, that is a proof that one exists.");
+
     /**
      * For test purposes only.
      */
@@ -49,6 +53,7 @@
         private static ByteArrayOutputStream err;
         private boolean verbose = false;
         private boolean interactive = false;
+        private boolean quiet = false;
 
         private MockConsoleApplication(PrintStream out, PrintStream err) {
             super(out, err);
@@ -82,6 +87,12 @@
             return interactive;
         }
 
+        /** {@inheritDoc} */
+        @Override
+        public boolean isQuiet() {
+            return quiet;
+        }
+
         public void setVerbose(boolean v) {
             verbose = v;
         }
@@ -89,12 +100,14 @@
         public void setInteractive(boolean inter) {
             interactive = inter;
         }
+
+        public void setQuiet(boolean q) {
+            quiet = q;
+        }
     }
 
     @Test()
     public void testWriteLineInOutputStream() throws UnsupportedEncodingException {
-        final LocalizableMessage msg = LocalizableMessage
-                .raw("If somebody wants a sheep, that is a proof that one exists.");
         final MockConsoleApplication ca = MockConsoleApplication.getDefault();
         ca.print(msg);
         assertThat(ca.getOut()).contains(msg.toString());
@@ -103,7 +116,6 @@
 
     @Test()
     public void testWriteLineInErrorStream() throws UnsupportedEncodingException {
-        final LocalizableMessage msg = LocalizableMessage.raw("Language is the source of misunderstandings.");
         final MockConsoleApplication ca = MockConsoleApplication.getDefault();
         ca.errPrintln(msg);
         assertThat(ca.getOut()).isEmpty();
@@ -112,8 +124,6 @@
 
     @Test()
     public void testWriteOutputStreamVerbose() throws UnsupportedEncodingException {
-        final LocalizableMessage msg = LocalizableMessage
-                .raw("If somebody wants a sheep, that is a proof that one exists.");
         final MockConsoleApplication ca = MockConsoleApplication.getDefault();
         ca.printVerboseMessage(msg);
         assertThat(ca.isVerbose()).isFalse();
@@ -128,7 +138,6 @@
 
     @Test()
     public void testWriteErrorStreamVerbose() throws UnsupportedEncodingException {
-        final LocalizableMessage msg = LocalizableMessage.raw("Language is the source of misunderstandings.");
         final MockConsoleApplication ca = MockConsoleApplication.getDefault();
         ca.errPrintVerboseMessage(msg);
         assertThat(ca.isVerbose()).isFalse();
@@ -149,9 +158,6 @@
      */
     @Test()
     public void testNonInteractiveApplicationShouldNotStdoutErrors() throws UnsupportedEncodingException {
-        final LocalizableMessage msg = LocalizableMessage.raw("Language is the source of misunderstandings.");
-        final LocalizableMessage msg2 = LocalizableMessage
-                .raw("If somebody wants a sheep, that is a proof that one exists.");
         final MockConsoleApplication ca = MockConsoleApplication.getDefault();
 
         assertFalse(ca.isInteractive());
@@ -170,10 +176,6 @@
      */
     @Test()
     public void testInteractiveApplicationShouldStdoutErrors() throws UnsupportedEncodingException {
-        final LocalizableMessage msg = LocalizableMessage.raw("Language is the source of misunderstandings.");
-        final LocalizableMessage msg2 = LocalizableMessage
-                .raw("If somebody wants a sheep, that is a proof that one exists.");
-
         final MockConsoleApplication ca = MockConsoleApplication.getDefault();
 
         assertFalse(ca.isInteractive());
@@ -186,4 +188,20 @@
         assertThat(ca.getOut()).contains(msg2.toString());
         assertThat(ca.getErr()).isEmpty();
     }
+
+    /**
+     * In quiet mode, only the stderr should contain lines.
+     * @throws UnsupportedEncodingException
+     */
+    @Test()
+    public void testQuietMode() throws UnsupportedEncodingException {
+        final MockConsoleApplication ca = MockConsoleApplication.getDefault();
+        ca.setQuiet(true);
+        assertTrue(ca.isQuiet());
+        ca.println(msg);
+        ca.errPrintln(msg2);
+        assertThat(ca.getOut()).isEmpty();
+        assertThat(ca.getErr()).contains(msg2.toString());
+        assertThat(ca.getErr()).doesNotContain(msg.toString());
+    }
 }

--
Gitblit v1.10.0