Repository: python-cmd2/cmd2

Issue:
Consider adding support for nested commands or subcommands
Many REPL frameworks have support for multi-level commands, where the command has sub-commands.

While this can be done with cmd2, there isn't any built-in support to make it easy.

Consider adding some built-in support to make it easy to have nested commands or subcommands.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c017ff36..24c11dec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,33 +1,40 @@
 ## 0.8.0 (TBD, 2018)
 * Bug Fixes
     * Fixed unit tests on Python 3.7 due to changes in how re.escape() behaves in Python 3.7
+    * Fixed a bug where unknown commands were getting saved in the history
 * Enhancements
     * Three new decorators for **do_*** commands to make argument parsing easier
         * **with_argument_list** decorator to change argument type from str to List[str]
             * **do_*** commands get a single argument which is a list of strings, as pre-parsed by shlex.split()
         * **with_argument_parser** decorator for strict argparse-based argument parsing of command arguments
             * **do_*** commands get a single argument which is the output of argparse.parse_args()
-        * **with_argparser_and_unknown_args** decorator for argparse-based argument parsing, but allowing unknown args
+        * **with_argparser_and_unknown_args** decorator for argparse-based argument parsing, but allows unknown args
             * **do_*** commands get two arguments, the output of argparse.parse_known_args()
-    *  See the **Argument Processing** section of the documentation for more information on these decorators
-        * Alternatively, see the **argparse_example.py** and **arg_print.py** examples
+    *  See the [Argument Processing](http://cmd2.readthedocs.io/en/latest/argument_processing.html) section of the documentation for more information on these decorators
+        * Alternatively, see the [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/master/examples/argpasre_example.py) 
+        and [arg_print.py](https://github.com/python-cmd2/cmd2/blob/master/examples/arg_print.py) examples
+    * Added support for Argpasre sub-commands when using the **with_argument_parser** or **with_argparser_and_unknown_args** decorators
+        * See [subcommands.py](https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py) for an example of how to use subcommands
+        * Tab-completion of sub-command names is automatically supported
     * The **__relative_load** command is now hidden from the help menu by default
         * This command is not intended to be called from the command line, only from within scripts
     * The **set** command now has an additional **-a/--all** option to also display read-only settings
-    * The **history** command can now run, edit, and save prior commands, in addition to the prior behavior of displaying prior commands.
+    * The **history** command can now run, edit, and save prior commands, in addition to displaying prior commands.
+    * The **history** command can now automatically generate a transcript file for regression testing
+        * This feature works imperfectly at the moment, but it is still quite useful
 * Commands Removed
     * The **cmdenvironment** has been removed and its functionality incorporated into the **-a/--all** argument to **set**
     * The **show** command has been removed.  Its functionality has always existing within **set** and continues to do so
-    * The **save** command has been removed. The capability to save prior commands is now part of the **history** command.
+    * The **save** command has been removed. The capability to save commands is now part of the **history** command.
     * The **run** command has been removed. The capability to run prior commands is now part of the **history** command.
 * Other changes
     * The **edit** command no longer allows you to edit prior commands. The capability to edit prior commands is now part of the **history** command. The **edit** command still allows you to edit arbitrary files.
     * the **autorun_on_edit** setting has been removed.
+    * For Python 3.4 and earlier, ``cmd2`` now has an additional dependency on the ``contextlib2`` module
 * Deprecations
     * The old **options** decorator for optparse-based argument parsing is now *deprecated*
-        * The old decorator is still present for now, but will eventually be removed in a future release
-        * ``cmd2`` no longer includes **optparse.make_option** so if your app needs it you need to import it directly from optparse
-
+        * The old decorator is still present for now, but will be removed in a future release
+        * ``cmd2`` no longer includes **optparse.make_option**, so if your app needs it import directly from optparse
 
 ## 0.7.9 (January 4, 2018)
 
diff --git a/README.md b/README.md
index 70fde5a8..f6fa6536 100755
--- a/README.md
+++ b/README.md
@@ -29,11 +29,11 @@ Main Features
 - Multi-line, case-insensitive, and abbreviated commands
 - Special-character command shortcuts (beyond cmd's `@` and `!`)
 - Settable environment parameters
-- Parsing commands with arguments using `argparse`
+- Parsing commands with arguments using `argparse`, including support for sub-commands
 - Unicode character support (*Python 3 only*)
-- Good tab-completion of commands, file system paths, and shell commands
+- Good tab-completion of commands, sub-commands, file system paths, and shell commands
 - Python 2.7 and 3.4+ support
-- Linux, macOS and Windows support
+- Windows, macOS, and Linux support
 - Trivial to provide built-in help for all commands
 - Built-in regression testing framework for your applications (transcript-based testing)
 
@@ -48,8 +48,9 @@ pip install -U cmd2
 
 cmd2 works with Python 2.7 and Python 3.4+ on Windows, macOS, and Linux. It is pure Python code with
 the only 3rd-party dependencies being on [six](https://pypi.python.org/pypi/six),
-[pyparsing](http://pyparsing.wikispaces.com), and [pyperclip](https://github.com/asweigart/pyperclip) 
-(on Windows, [pyreadline](https://pypi.python.org/pypi/pyreadline) is an additional dependency).
+[pyparsing](http://pyparsing.wikispaces.com), and [pyperclip](https://github.com/asweigart/pyperclip).
+Windows has an additional dependency on [pyreadline](https://pypi.python.org/pypi/pyreadline) and Python
+3.4 and earlier have an additional dependency on [contextlib2](https://pypi.python.org/pypi/contextlib2).
 
 For information on other installation options, see
 [Installation Instructions](https://cmd2.readthedocs.io/en/latest/install.html) in the cmd2
diff --git a/cmd2.py b/cmd2.py
index 378ac097..e77f4557 100755
--- a/cmd2.py
+++ b/cmd2.py
@@ -73,6 +73,12 @@ try:
 except ImportError:
     import subprocess
 
+# Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout
+if sys.version_info < (3, 5):
+    from contextlib2 import redirect_stdout, redirect_stderr
+else:
+    from contextlib import redirect_stdout, redirect_stderr
+
 # Detect whether IPython is installed to determine if the built-in "ipy" command should be included
 ipython_available = True
 try:
@@ -106,7 +112,7 @@ if six.PY2 and sys.platform.startswith('lin'):
     except ImportError:
         pass
 
-__version__ = '0.8.0a'
+__version__ = '0.8.0'
 
 # Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past
 pyparsing.ParserElement.enablePackrat()
@@ -272,10 +278,13 @@ def with_argument_list(func):
     return cmd_wrapper
 
 
-def with_argparser_and_unknown_args(argparser):
-    """A decorator to alter a cmd2 method to populate its ``args``
-    argument by parsing arguments with the given instance of
-    argparse.ArgumentParser, but also returning unknown args as a list.
+def with_argparser_and_unknown_args(argparser, subcommand_names=None):
+    """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given
+    instance of argparse.ArgumentParser, but also returning unknown args as a list.
+
+    :param argparser: argparse.ArgumentParser - given instance of ArgumentParser
+    :param subcommand_names: List[str] - list of subcommand names for this parser (used for tab-completion)
+    :return: function that gets passed parsed args and a list of unknown args
     """
     def arg_decorator(func):
         def cmd_wrapper(instance, cmdline):
@@ -292,14 +301,26 @@ def with_argparser_and_unknown_args(argparser):
             argparser.description = func.__doc__
 
         cmd_wrapper.__doc__ = argparser.format_help()
+
+        # Mark this function as having an argparse ArgumentParser (used by do_help)
+        cmd_wrapper.__dict__['has_parser'] = True
+
+        # If there are subcommands, store their names to support tab-completion of subcommand names
+        if subcommand_names is not None:
+            cmd_wrapper.__dict__['subcommand_names'] = subcommand_names
+
         return cmd_wrapper
+
     return arg_decorator
 
 
-def with_argument_parser(argparser):
-    """A decorator to alter a cmd2 method to populate its ``args``
-    argument by parsing arguments with the given instance of
-    argparse.ArgumentParser.
+def with_argparser(argparser, subcommand_names=None):
+    """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
+    with the given instance of argparse.ArgumentParser.
+
+    :param argparser: argparse.ArgumentParser - given instance of ArgumentParser
+    :param subcommand_names: List[str] - list of subcommand names for this parser (used for tab-completion)
+    :return: function that gets passed parsed args
     """
     def arg_decorator(func):
         def cmd_wrapper(instance, cmdline):
@@ -316,7 +337,16 @@ def with_argument_parser(argparser):
             argparser.description = func.__doc__
 
         cmd_wrapper.__doc__ = argparser.format_help()
+
+        # Mark this function as having an argparse ArgumentParser (used by do_help)
+        cmd_wrapper.__dict__['has_parser'] = True
+
+        # If there are subcommands, store their names to support tab-completion of subcommand names
+        if subcommand_names is not None:
+            cmd_wrapper.__dict__['subcommand_names'] = subcommand_names
+
         return cmd_wrapper
+
     return arg_decorator
 
 
@@ -644,6 +674,9 @@ class Cmd(cmd.Cmd):
         # Used when piping command output to a shell command
         self.pipe_proc = None
 
+        # Used by complete() for readline tab completion
+        self.completion_matches = []
+
     # -----  Methods related to presenting output to the user -----
 
     @property
@@ -733,7 +766,7 @@ class Cmd(cmd.Cmd):
 
     # noinspection PyMethodOverriding
     def completenames(self, text, line, begidx, endidx):
-        """Override of cmd2 method which completes command names both for command completion and help."""
+        """Override of cmd method which completes command names both for command completion and help."""
         command = text
         if self.case_insensitive:
             command = text.lower()
@@ -747,6 +780,91 @@ class Cmd(cmd.Cmd):
 
         return cmd_completion
 
+    # noinspection PyUnusedLocal
+    def complete_subcommand(self, text, line, begidx, endidx):
+        """Readline tab-completion method for completing argparse sub-command names."""
+        command, args, foo = self.parseline(line)
+        arglist = args.split()
+
+        if len(arglist) <= 1 and command + ' ' + args == line:
+            funcname = self._func_named(command)
+            if funcname:
+                # Check to see if this function was decorated with an argparse ArgumentParser
+                func = getattr(self, funcname)
+                subcommand_names = func.__dict__.get('subcommand_names', None)
+
+                # If this command has subcommands
+                if subcommand_names is not None:
+                    arg = ''
+                    if arglist:
+                        arg = arglist[0]
+
+                    matches = [sc for sc in subcommand_names if sc.startswith(arg)]
+
+                    # If completing the sub-command name and get exactly 1 result and are at end of line, add a space
+                    if len(matches) == 1 and endidx == len(line):
+                        matches[0] += ' '
+                    return matches
+
+        return []
+
+    def complete(self, text, state):
+        """Override of command method which returns the next possible completion for 'text'.
+
+        If a command has not been entered, then complete against command list.
+        Otherwise try to call complete_<command> to get list of completions.
+
+        This method gets called directly by readline because it is set as the tab-completion function.
+
+        This completer function is called as complete(text, state), for state in 0, 1, 2, …, until it returns a
+        non-string value. It should return the next possible completion starting with text.
+
+        :param text: str - the current word that user is typing
+        :param state: int - non-negative integer
+        """
+        if state == 0:
+            import readline
+            origline = readline.get_line_buffer()
+            line = origline.lstrip()
+            stripped = len(origline) - len(line)
+            begidx = readline.get_begidx() - stripped
+            endidx = readline.get_endidx() - stripped
+            if begidx > 0:
+                command, args, foo = self.parseline(line)
+                if command == '':
+                    compfunc = self.completedefault
+                else:
+                    arglist = args.split()
+
+                    compfunc = None
+                    # If the user has entered no more than a single argument after the command name
+                    if len(arglist) <= 1 and command + ' ' + args == line:
+                        funcname = self._func_named(command)
+                        if funcname:
+                            # Check to see if this function was decorated with an argparse ArgumentParser
+                            func = getattr(self, funcname)
+                            subcommand_names = func.__dict__.get('subcommand_names', None)
+
+                            # If this command has subcommands
+                            if subcommand_names is not None:
+                                compfunc = self.complete_subcommand
+
+                    if compfunc is None:
+                        # This command either doesn't have sub-commands or the user is past the point of entering one
+                        try:
+                            compfunc = getattr(self, 'complete_' + command)
+                        except AttributeError:
+                            compfunc = self.completedefault
+            else:
+                compfunc = self.completenames
+
+            self.completion_matches = compfunc(text, line, begidx, endidx)
+
+        try:
+            return self.completion_matches[state]
+        except IndexError:
+            return None
+
     def precmd(self, statement):
         """Hook method executed just before the command is processed by ``onecmd()`` and after adding it to the history.
 
@@ -854,8 +972,7 @@ class Cmd(cmd.Cmd):
             (stop, statement) = self.postparsing_precmd(statement)
             if stop:
                 return self.postparsing_postcmd(stop)
-            if statement.parsed.command not in self.excludeFromHistory:
-                self.history.append(statement.parsed.raw)
+
             try:
                 if self.allow_redirection:
                     self._redirect_output(statement)
@@ -904,7 +1021,11 @@ class Cmd(cmd.Cmd):
         self.cmdqueue = list(cmds) + self.cmdqueue
         try:
             while self.cmdqueue and not stop:
-                stop = self.onecmd_plus_hooks(self.cmdqueue.pop(0))
+                line = self.cmdqueue.pop(0)
+                if self.echo and line != 'eos':
+                    self.poutput('{}{}'.format(self.prompt, line))
+
+                stop = self.onecmd_plus_hooks(line)
         finally:
             # Clear out the command queue and script directory stack, just in
             # case we hit an error and they were not completed.
@@ -1046,6 +1167,10 @@ class Cmd(cmd.Cmd):
         if not funcname:
             return self.default(statement)
 
+        # Since we have a valid command store it in the history
+        if statement.parsed.command not in self.excludeFromHistory:
+            self.history.append(statement.parsed.raw)
+
         try:
             func = getattr(self, funcname)
         except AttributeError:
@@ -1198,8 +1323,20 @@ class Cmd(cmd.Cmd):
             # Getting help for a specific command
             funcname = self._func_named(arglist[0])
             if funcname:
-                # No special behavior needed, delegate to cmd base class do_help()
-                cmd.Cmd.do_help(self, funcname[3:])
+                # Check to see if this function was decorated with an argparse ArgumentParser
+                func = getattr(self, funcname)
+                if func.__dict__.get('has_parser', False):
+                    # Function has an argparser, so get help based on all the arguments in case there are sub-commands
+                    new_arglist = arglist[1:]
+                    new_arglist.append('-h')
+
+                    # Temporarily redirect all argparse output to both sys.stdout and sys.stderr to self.stdout
+                    with redirect_stdout(self.stdout):
+                        with redirect_stderr(self.stdout):
+                            func(new_arglist)
+                else:
+                    # No special behavior needed, delegate to cmd base class do_help()
+                    cmd.Cmd.do_help(self, funcname[3:])
         else:
             # Show a menu of what commands help can be gotten for
             self._help_menu()
@@ -1340,7 +1477,7 @@ class Cmd(cmd.Cmd):
     set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter')
     set_parser.add_argument('settable', nargs='*', help='[param_name] [value]')
 
-    @with_argument_parser(set_parser)
+    @with_argparser(set_parser)
     def do_set(self, args):
         """Sets a settable parameter or shows current settings of parameters.
 
@@ -1692,8 +1829,9 @@ Paths or arguments that contain spaces must be enclosed in quotes
     history_parser_group.add_argument('-r', '--run', action='store_true', help='run selected history items')
     history_parser_group.add_argument('-e', '--edit', action='store_true',
                                       help='edit and then run selected history items')
-    history_parser_group.add_argument('-o', '--output-file', metavar='FILE', help='output to file')
-    history_parser.add_argument('-s', '--script', action='store_true', help='script format; no separation lines')
+    history_parser_group.add_argument('-s', '--script', action='store_true', help='script format; no separation lines')
+    history_parser_group.add_argument('-o', '--output-file', metavar='FILE', help='output commands to a script file')
+    history_parser_group.add_argument('-t', '--transcript', help='output commands and results to a transcript file')
     _history_arg_help = """empty               all history items
 a                   one history item by number
 a..b, a:b, a:, ..b  items by indices (inclusive)
@@ -1701,7 +1839,7 @@ a..b, a:b, a:, ..b  items by indices (inclusive)
 /regex/             items matching regular expression"""
     history_parser.add_argument('arg', nargs='?', help=_history_arg_help)
 
-    @with_argument_parser(history_parser)
+    @with_argparser(history_parser)
     def do_history(self, args):
         """View, run, edit, and save previously entered commands."""
         # If an argument was supplied, then retrieve partial contents of the history
@@ -1722,7 +1860,8 @@ a..b, a:b, a:, ..b  items by indices (inclusive)
         else:
             # If no arg given, then retrieve the entire history
             cowardly_refuse_to_run = True
-            history = self.history
+            # Get a copy of the history so it doesn't get mutated while we are using it
+            history = self.history[:]
 
         if args.run:
             if cowardly_refuse_to_run:
@@ -1755,6 +1894,28 @@ a..b, a:b, a:, ..b  items by indices (inclusive)
                 self.pfeedback('{} command{} saved to {}'.format(len(history), plural, args.output_file))
             except Exception as e:
                 self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False)
+        elif args.transcript:
+            # Make sure echo is on so commands print to standard out
+            saved_echo = self.echo
+            self.echo = True
+
+            # Redirect stdout to the transcript file
+            saved_self_stdout = self.stdout
+            self.stdout = open(args.transcript, 'w')
+
+            # Run all of the commands in the history with output redirected to transcript and echo on
+            self.runcmds_plus_hooks(history)
+
+            # Restore stdout to its original state
+            self.stdout.close()
+            self.stdout = saved_self_stdout
+
+            # Set echo back to its original state
+            self.echo = saved_echo
+
+            plural = 's' if len(history) > 1 else ''
+            self.pfeedback('{} command{} and outputs saved to transcript file {!r}'.format(len(history), plural,
+                                                                                           args.transcript))
         else:
             # Display the history items retrieved
             for hi in history:
diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst
index cc08e4e5..2a433bc7 100644
--- a/docs/argument_processing.rst
+++ b/docs/argument_processing.rst
@@ -13,7 +13,7 @@ Argument Processing
 4. Adds the usage message from the argument parser to your command.
 5. Checks if the ``-h/--help`` option is present, and if so, display the help message for the command
 
-These features are all provided by the ``@with_argument_parser`` decorator.
+These features are all provided by the ``@with_argparser`` decorator.
 
 Using the argument parser decorator
 ===================================
@@ -21,7 +21,7 @@ Using the argument parser decorator
 For each command in the ``cmd2`` subclass which requires argument parsing,
 create an instance of ``argparse.ArgumentParser()`` which can parse the
 input appropriately for the command. Then decorate the command method with
-the ``@with_argument_parser`` decorator, passing the argument parser as the
+the ``@with_argparser`` decorator, passing the argument parser as the
 first parameter to the decorator. This changes the second argumen to the command method, which will contain the results
 of ``ArgumentParser.parse_args()``.
 
@@ -33,7 +33,7 @@ Here's what it looks like::
       argparser.add_argument('-r', '--repeat', type=int, help='output [n] times')
       argparser.add_argument('word', nargs='?', help='word to say')
 
-      @with_argument_parser(argparser)
+      @with_argparser(argparser)
       def do_speak(self, opts)
          """Repeats what you tell me to."""
          arg = opts.word
@@ -47,7 +47,7 @@ Here's what it looks like::
 
 .. note::
 
-   The ``@with_argument_parser`` decorator sets the ``prog`` variable in
+   The ``@with_argparser`` decorator sets the ``prog`` variable in
    the argument parser based on the name of the method it is decorating.
    This will override anything you specify in ``prog`` variable when
    creating the argument parser.
@@ -57,14 +57,14 @@ Help Messages
 =============
 
 By default, cmd2 uses the docstring of the command method when a user asks
-for help on the command. When you use the ``@with_argument_parser``
+for help on the command. When you use the ``@with_argparser``
 decorator, the docstring for the ``do_*`` method is used to set the description for the ``argparse.ArgumentParser`` is
 With this code::
 
    argparser = argparse.ArgumentParser()
    argparser.add_argument('tag', help='tag')
    argparser.add_argument('content', nargs='+', help='content to surround with tag')
-   @with_argument_parser(argparser)
+   @with_argparser(argparser)
    def do_tag(self, args):
       """create a html tag"""
       self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content)))
@@ -92,7 +92,7 @@ docstring on your method empty::
    argparser = argparse.ArgumentParser(description='create an html tag')
    argparser.add_argument('tag', help='tag')
    argparser.add_argument('content', nargs='+', help='content to surround with tag')
-   @with_argument_parser(argparser)
+   @with_argparser(argparser)
    def do_tag(self, args):
       self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content)))
       self.stdout.write('\n')
@@ -121,7 +121,7 @@ To add additional text to the end of the generated help message, use the ``epilo
    )
    argparser.add_argument('tag', help='tag')
    argparser.add_argument('content', nargs='+', help='content to surround with tag')
-   @with_argument_parser(argparser)
+   @with_argparser(argparser)
    def do_tag(self, args):
       self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content)))
       self.stdout.write('\n')
@@ -166,14 +166,14 @@ The default behavior of ``cmd2`` is to pass the user input directly to your
 Using the argument parser decorator and also receiving a a list of unknown positional arguments
 ===============================================================================================
 If you want all unknown arguments to be passed to your command as a list of strings, then
-decorate the command method with the ``@with_argparser_and_list`` decorator.
+decorate the command method with the ``@with_argparser_and_unknown_args`` decorator.
 
 Here's what it looks like::
 
     dir_parser = argparse.ArgumentParser()
     dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line")
 
-    @with_argparser_and_list(dir_parser)
+    @with_argparser_and_unknown_args(dir_parser)
     def do_dir(self, args, unknown):
         """List contents of current directory."""
         # No arguments for this command
@@ -188,6 +188,15 @@ Here's what it looks like::
 
         ...
 
+Sub-commands
+============
+Sub-commands are supported for commands using either the ``@with_argparser`` or
+``@with_argparser_and_unknown_args`` decorator.  The syntax for supporting them is based on argparse sub-parsers.
+
+See the subcommands_ example to learn more about how to use sub-commands in your ``cmd2`` application.
+
+.. _subcommands: https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py
+
 Deprecated optparse support
 ===========================
 
diff --git a/docs/conf.py b/docs/conf.py
index fd3e9476..d4ef14bf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -60,9 +60,9 @@ author = 'Catherine Devlin and Todd Leonhardt'
 # built documents.
 #
 # The short X.Y version.
-version = '0.7'
+version = '0.8'
 # The full version, including alpha/beta/rc tags.
-release = '0.7.9'
+release = '0.8.0'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/docs/install.rst b/docs/install.rst
index 19cbdd78..2c247a3e 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -128,6 +128,11 @@ If you wish to permanently uninstall ``cmd2``, this can also easily be done with
 
     pip uninstall cmd2
 
+Extra requirement for Python 3.4 and earlier
+--------------------------------------------
+``cmd2`` requires the ``contextlib2`` module for Python 3.4 and earlier.  This is used to temporarily redirect
+stdout and stderr.
+
 Extra requirement for Python 2.7 only
 -------------------------------------
 If you want to be able to pipe the output of commands to a shell command on Python 2.7, then you will need one
diff --git a/examples/arg_print.py b/examples/arg_print.py
index 1b18cdf0..8b02bc51 100755
--- a/examples/arg_print.py
+++ b/examples/arg_print.py
@@ -14,7 +14,7 @@ import argparse
 import cmd2
 import pyparsing
 
-from cmd2 import with_argument_list, with_argument_parser, with_argparser_and_unknown_args
+from cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args
 
 
 class ArgumentAndOptionPrinter(cmd2.Cmd):
@@ -47,7 +47,7 @@ class ArgumentAndOptionPrinter(cmd2.Cmd):
     oprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
     oprint_parser.add_argument('words', nargs='+', help='words to print')
 
-    @with_argument_parser(oprint_parser)
+    @with_argparser(oprint_parser)
     def do_oprint(self, args):
         """Print the options and argument list this options command was called with."""
         print('oprint was called with the following\n\toptions: {!r}'.format(args))
diff --git a/examples/argparse_example.py b/examples/argparse_example.py
index 9f6548de..fbb2b1dc 100755
--- a/examples/argparse_example.py
+++ b/examples/argparse_example.py
@@ -14,7 +14,7 @@ verifying that the output produced matches the transcript.
 import argparse
 import sys
 
-from cmd2 import Cmd, options, with_argument_parser, with_argument_list
+from cmd2 import Cmd, options, with_argparser, with_argument_list
 from optparse import make_option
 
 
@@ -47,7 +47,7 @@ class CmdLineApp(Cmd):
     speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
     speak_parser.add_argument('words', nargs='+', help='words to say')
 
-    @with_argument_parser(speak_parser)
+    @with_argparser(speak_parser)
     def do_speak(self, args):
         """Repeats what you tell me to."""
         words = []
@@ -68,7 +68,7 @@ class CmdLineApp(Cmd):
     tag_parser.add_argument('tag', help='tag')
     tag_parser.add_argument('content', nargs='+', help='content to surround with tag')
 
-    @with_argument_parser(tag_parser)
+    @with_argparser(tag_parser)
     def do_tag(self, args):
         """create a html tag"""
         self.poutput('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content)))
diff --git a/examples/example.py b/examples/example.py
index 4ba0d29a..c66f0e60 100755
--- a/examples/example.py
+++ b/examples/example.py
@@ -14,7 +14,7 @@ the transcript.
 import random
 import argparse
 
-from cmd2 import Cmd, with_argument_parser
+from cmd2 import Cmd, with_argparser
 
 
 class CmdLineApp(Cmd):
@@ -44,7 +44,7 @@ class CmdLineApp(Cmd):
     speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
     speak_parser.add_argument('words', nargs='+', help='words to say')
 
-    @with_argument_parser(speak_parser)
+    @with_argparser(speak_parser)
     def do_speak(self, args):
         """Repeats what you tell me to."""
         words = []
@@ -66,7 +66,7 @@ class CmdLineApp(Cmd):
     mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat')
     mumble_parser.add_argument('words', nargs='+', help='words to say')
 
-    @with_argument_parser(mumble_parser)
+    @with_argparser(mumble_parser)
     def do_mumble(self, args):
         """Mumbles what you tell me to."""
         repetitions = args.repeat or 1
diff --git a/examples/pirate.py b/examples/pirate.py
index dd9fd98c..cfe545d6 100755
--- a/examples/pirate.py
+++ b/examples/pirate.py
@@ -7,7 +7,7 @@ presented as part of her PyCon 2010 talk.
 It demonstrates many features of cmd2.
 """
 import argparse
-from cmd2 import Cmd, with_argument_parser
+from cmd2 import Cmd, with_argparser
 
 
 class Pirate(Cmd):
@@ -25,13 +25,13 @@ class Pirate(Cmd):
         """Initialize the base class as well as this one"""
         Cmd.__init__(self)
         # prompts and defaults
-        self.gold = 3
+        self.gold = 0
         self.initial_gold = self.gold
         self.prompt = 'arrr> '
 
     def default(self, line):
         """This handles unknown commands."""
-        print('What mean ye by "{0}"?'.format(line))
+        self.poutput('What mean ye by "{0}"?'.format(line))
 
     def precmd(self, line):
         """Runs just before a command line is parsed, but after the prompt is presented."""
@@ -41,10 +41,10 @@ class Pirate(Cmd):
     def postcmd(self, stop, line):
         """Runs right before a command is about to return."""
         if self.gold != self.initial_gold:
-            print('Now we gots {0} doubloons'
+            self.poutput('Now we gots {0} doubloons'
                   .format(self.gold))
         if self.gold < 0:
-            print("Off to debtorrr's prison.")
+            self.poutput("Off to debtorrr's prison.")
             stop = True
         return stop
 
@@ -61,30 +61,30 @@ class Pirate(Cmd):
             self.gold -= int(arg)
         except ValueError:
             if arg:
-                print('''What's "{0}"?  I'll take rrrum.'''.format(arg))
+                self.poutput('''What's "{0}"?  I'll take rrrum.'''.format(arg))
             self.gold -= 1
 
     def do_quit(self, arg):
         """Quit the application gracefully."""
-        print("Quiterrr!")
+        self.poutput("Quiterrr!")
         return True
 
     def do_sing(self, arg):
         """Sing a colorful song."""
-        print(self.colorize(arg, self.songcolor))
+        self.poutput(self.colorize(arg, self.songcolor))
 
     yo_parser = argparse.ArgumentParser()
     yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'")
     yo_parser.add_argument('-c', '--commas', action='store_true', help='Intersperse commas')
     yo_parser.add_argument('beverage', help='beverage to drink with the chant')
 
-    @with_argument_parser(yo_parser)
+    @with_argparser(yo_parser)
     def do_yo(self, args):
         """Compose a yo-ho-ho type chant with flexible options."""
         chant = ['yo'] + ['ho'] * args.ho
         separator = ', ' if args.commas else ' '
         chant = separator.join(chant)
-        print('{0} and a bottle of {1}'.format(chant, args.beverage))
+        self.poutput('{0} and a bottle of {1}'.format(chant, args.beverage))
 
 
 if __name__ == '__main__':
diff --git a/examples/subcommands.py b/examples/subcommands.py
new file mode 100755
index 00000000..e77abc61
--- /dev/null
+++ b/examples/subcommands.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""A simple example demonstrating how to use Argparse to support sub-commands.
+
+
+This example shows an easy way for a single command to have many subcommands, each of which takes different arguments
+and provides separate contextual help.
+"""
+import argparse
+
+import cmd2
+from cmd2 import with_argparser
+
+
+class SubcommandsExample(cmd2.Cmd):
+    """ Example cmd2 application where we a base command which has a couple subcommands."""
+
+    def __init__(self):
+        cmd2.Cmd.__init__(self)
+
+    # sub-command functions for the base command
+    def base_foo(self, args):
+        """foo subcommand of base command"""
+        self.poutput(args.x * args.y)
+
+    def base_bar(self, args):
+        """bar sucommand of base command"""
+        self.poutput('((%s))' % args.z)
+
+    # create the top-level parser for the base command
+    base_parser = argparse.ArgumentParser(prog='base')
+    base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
+
+    # create the parser for the "foo" sub-command
+    parser_foo = base_subparsers.add_parser('foo', help='foo help')
+    parser_foo.add_argument('-x', type=int, default=1, help='integer')
+    parser_foo.add_argument('y', type=float, help='float')
+    parser_foo.set_defaults(func=base_foo)
+
+    # create the parser for the "bar" sub-command
+    parser_bar = base_subparsers.add_parser('bar', help='bar help')
+    parser_bar.add_argument('z', help='string')
+    parser_bar.set_defaults(func=base_bar)
+
+    # Create a list of subcommand names, which is used to enable tab-completion of sub-commands
+    subcommands = ['foo', 'bar']
+
+    @with_argparser(base_parser, subcommands)
+    def do_base(self, args):
+        """Base command help"""
+        try:
+            # Call whatever sub-command function was selected
+            args.func(self, args)
+        except AttributeError:
+            # No sub-command was provided, so as called
+            self.do_help('base')
+
+
+if __name__ == '__main__':
+    app = SubcommandsExample()
+    app.cmdloop()
diff --git a/examples/exampleSession.txt b/examples/transcripts/exampleSession.txt
similarity index 100%
rename from examples/exampleSession.txt
rename to examples/transcripts/exampleSession.txt
diff --git a/examples/transcripts/pirate.transcript b/examples/transcripts/pirate.transcript
new file mode 100644
index 00000000..570f0cd7
--- /dev/null
+++ b/examples/transcripts/pirate.transcript
@@ -0,0 +1,10 @@
+arrr> loot
+Now we gots 1 doubloons
+arrr> loot
+Now we gots 2 doubloons
+arrr> loot
+Now we gots 3 doubloons
+arrr> drink 3
+Now we gots 0 doubloons
+arrr> yo --ho 3 rum
+yo ho ho ho and a bottle of rum
diff --git a/examples/transcript_regex.txt b/examples/transcripts/transcript_regex.txt
similarity index 100%
rename from examples/transcript_regex.txt
rename to examples/transcripts/transcript_regex.txt
diff --git a/setup.py b/setup.py
index 8d5b7619..58f8e4cd 100755
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ Setuptools setup file, used to install or test 'cmd2'
 import sys
 from setuptools import setup
 
-VERSION = '0.8.0a'
+VERSION = '0.8.0'
 DESCRIPTION = "cmd2 - a tool for building interactive command line applications in Python"
 LONG_DESCRIPTION = """cmd2 is a tool for building interactive command line applications in Python. Its goal is to make 
 it quick and easy for developers to build feature-rich and user-friendly interactive command line applications.  It 
@@ -62,9 +62,19 @@ Topic :: Software Development :: Libraries :: Python Modules
 """.splitlines())))
 
 INSTALL_REQUIRES = ['pyparsing >= 2.0.1', 'pyperclip', 'six']
+
+# Windows also requires pyreadline to ensure tab completion works
 if sys.platform.startswith('win'):
     INSTALL_REQUIRES += ['pyreadline']
 
+# Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout
+if sys.version_info < (3, 5):
+    INSTALL_REQUIRES += ['contextlib2']
+
+# Python 2.7 also requires subprocess32
+if sys.version_info < (3, 0):
+    INSTALL_REQUIRES += ['subprocess32']
+
 # unittest.mock was added in Python 3.3.  mock is a backport of unittest.mock to all versions of Python
 TESTS_REQUIRE = ['mock', 'pytest']
 DOCS_REQUIRE = ['sphinx', 'sphinx_rtd_theme', 'pyparsing', 'pyperclip', 'six']


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 021af193..319e54fe 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -19,7 +19,7 @@ edit  help  history  load  py  pyscript  quit  set  shell  shortcuts
 """
 
 # Help text for the history command
-HELP_HISTORY = """usage: history [-h] [-r | -e | -o FILE] [-s] [arg]
+HELP_HISTORY = """usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg]
 
 View, run, edit, and save previously entered commands.
 
@@ -34,10 +34,11 @@ optional arguments:
   -h, --help            show this help message and exit
   -r, --run             run selected history items
   -e, --edit            edit and then run selected history items
-  -o FILE, --output-file FILE
-                        output to file
   -s, --script          script format; no separation lines
-
+  -o FILE, --output-file FILE
+                        output commands to a script file
+  -t TRANSCRIPT, --transcript TRANSCRIPT
+                        output commands and results to a transcript file
 """
 
 # Output from the shortcuts command with default built-in shortcuts
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index 21e81603..d3646046 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -8,6 +8,7 @@ import pytest
 import cmd2
 from conftest import run_cmd, StdOut
 
+
 class ArgparseApp(cmd2.Cmd):
     def __init__(self):
         self.maxrepeats = 3
@@ -19,7 +20,7 @@ class ArgparseApp(cmd2.Cmd):
     say_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
     say_parser.add_argument('words', nargs='+', help='words to say')
 
-    @cmd2.with_argument_parser(say_parser)
+    @cmd2.with_argparser(say_parser)
     def do_say(self, args):
         """Repeat what you tell me to."""
         words = []
@@ -40,7 +41,7 @@ class ArgparseApp(cmd2.Cmd):
     tag_parser.add_argument('tag', help='tag')
     tag_parser.add_argument('content', nargs='+', help='content to surround with tag')
 
-    @cmd2.with_argument_parser(tag_parser)
+    @cmd2.with_argparser(tag_parser)
     def do_tag(self, args):
         self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content)))
         self.stdout.write('\n')
@@ -162,3 +163,88 @@ def test_arglist(argparse_app):
 def test_arglist_decorator_twice(argparse_app):
     out = run_cmd(argparse_app, 'arglisttwice "we  should" get these')
     assert out[0] == 'we  should get these'
+
+
+class SubcommandApp(cmd2.Cmd):
+    """ Example cmd2 application where we a base command which has a couple subcommands."""
+
+    def __init__(self):
+        cmd2.Cmd.__init__(self)
+
+    # sub-command functions for the base command
+    def base_foo(self, args):
+        """foo subcommand of base command"""
+        self.poutput(args.x * args.y)
+
+    def base_bar(self, args):
+        """bar sucommand of base command"""
+        self.poutput('((%s))' % args.z)
+
+    # create the top-level parser for the base command
+    base_parser = argparse.ArgumentParser(prog='base')
+    base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
+
+    # create the parser for the "foo" sub-command
+    parser_foo = base_subparsers.add_parser('foo', help='foo help')
+    parser_foo.add_argument('-x', type=int, default=1, help='integer')
+    parser_foo.add_argument('y', type=float, help='float')
+    parser_foo.set_defaults(func=base_foo)
+
+    # create the parser for the "bar" sub-command
+    parser_bar = base_subparsers.add_parser('bar', help='bar help')
+    parser_bar.add_argument('z', help='string')
+    parser_bar.set_defaults(func=base_bar)
+
+    # Create a list of subcommand names, which is used to enable tab-completion of sub-commands
+    subcommands = ['foo', 'bar']
+
+    @cmd2.with_argparser_and_unknown_args(base_parser, subcommands)
+    def do_base(self, args, arglist):
+        """Base command help"""
+        try:
+            # Call whatever sub-command function was selected
+            args.func(self, args)
+        except AttributeError:
+            # No sub-command was provided, so as called
+            self.do_help('base')
+
+@pytest.fixture
+def subcommand_app():
+    app = SubcommandApp()
+    app.stdout = StdOut()
+    return app
+
+
+def test_subcommand_foo(subcommand_app):
+    out = run_cmd(subcommand_app, 'base foo -x2 5.0')
+    assert out == ['10.0']
+
+
+def test_subcommand_bar(subcommand_app):
+    out = run_cmd(subcommand_app, 'base bar baz')
+    assert out == ['((baz))']
+
+def test_subcommand_invalid(subcommand_app, capsys):
+    run_cmd(subcommand_app, 'base baz')
+    out, err = capsys.readouterr()
+    err = err.splitlines()
+    assert err[0].startswith('usage: base')
+    assert err[1].startswith("base: error: invalid choice: 'baz'")
+
+def test_subcommand_base_help(subcommand_app):
+    out = run_cmd(subcommand_app, 'help base')
+    assert out[0].startswith('usage: base')
+    assert out[1] == ''
+    assert out[2] == 'Base command help'
+
+def test_subcommand_help(subcommand_app):
+    out = run_cmd(subcommand_app, 'help base foo')
+    assert out[0].startswith('usage: base foo')
+    assert out[1] == ''
+    assert out[2] == 'positional arguments:'
+
+
+def test_subcommand_invalid_help(subcommand_app):
+    out = run_cmd(subcommand_app, 'help base baz')
+    assert out[0].startswith('usage: base')
+    assert out[1].startswith("base: error: invalid choice: 'baz'")
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 30308dd7..186def65 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -25,7 +25,7 @@ from conftest import run_cmd, normalize, BASE_HELP, HELP_HISTORY, SHORTCUTS_TXT,
 
 
 def test_ver():
-    assert cmd2.__version__ == '0.8.0a'
+    assert cmd2.__version__ == '0.8.0'
 
 
 def test_empty_statement(base_app):
@@ -41,19 +41,24 @@ def test_base_help(base_app):
 
 def test_base_help_history(base_app):
     out = run_cmd(base_app, 'help history')
-    expected = normalize(HELP_HISTORY)
-    assert out == expected
+    assert out == normalize(HELP_HISTORY)
 
 def test_base_argparse_help(base_app, capsys):
+    # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense
     run_cmd(base_app, 'set -h')
     out, err = capsys.readouterr()
-    expected = run_cmd(base_app, 'help set')
-    assert normalize(base_app.do_set.__doc__ + str(err)) == expected
+    out1 = out.splitlines()
+
+    out2 = run_cmd(base_app, 'help set')
+
+    assert out1 == out2
+    assert out1[0].startswith('usage: set')
+    assert out1[1] == ''
+    assert out1[2].startswith('Sets a settable parameter')
 
 def test_base_invalid_option(base_app, capsys):
     run_cmd(base_app, 'set -z')
     out, err = capsys.readouterr()
-    run_cmd(base_app, 'help set')
     expected = ['usage: set [-h] [-a] [-l] [settable [settable ...]]', 'set: error: unrecognized arguments: -z']
     assert normalize(str(err)) == expected
 
@@ -605,8 +610,7 @@ def test_input_redirection(base_app, request):
 
     # Verify that redirecting input ffom a file works
     out = run_cmd(base_app, 'help < {}'.format(filename))
-    expected = normalize(HELP_HISTORY)
-    assert out == expected
+    assert out == normalize(HELP_HISTORY)
 
 
 def test_pipe_to_shell(base_app, capsys):
diff --git a/tests/test_completion.py b/tests/test_completion.py
index efc32986..70f77d0a 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -8,10 +8,13 @@ file system paths, and shell commands.
 Copyright 2017 Todd Leonhardt <todd.leonhardt@gmail.com>
 Released under MIT license, see LICENSE file
 """
+import argparse
 import os
+import readline
 import sys
 
 import cmd2
+import mock
 import pytest
 
 
@@ -35,6 +38,100 @@ def test_cmd2_command_completion_single_end(cmd2_app):
     # It is at end of line, so extra space is present
     assert cmd2_app.completenames(text, line, begidx, endidx) == ['help ']
 
+def test_complete_command_single_end(cmd2_app):
+    text = 'he'
+    line = 'he'
+    state = 0
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    def get_line():
+        return line
+
+    def get_begidx():
+        return begidx
+
+    def get_endidx():
+        return endidx
+
+    with mock.patch.object(readline, 'get_line_buffer', get_line):
+        with mock.patch.object(readline, 'get_begidx', get_begidx):
+            with mock.patch.object(readline, 'get_endidx', get_endidx):
+                # Run the readline tab-completion function with readline mocks in place
+                completion = cmd2_app.complete(text, state)
+    assert completion == 'help '
+
+def test_complete_command_invalid_state(cmd2_app):
+    text = 'he'
+    line = 'he'
+    state = 1
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    def get_line():
+        return line
+
+    def get_begidx():
+        return begidx
+
+    def get_endidx():
+        return endidx
+
+    with mock.patch.object(readline, 'get_line_buffer', get_line):
+        with mock.patch.object(readline, 'get_begidx', get_begidx):
+            with mock.patch.object(readline, 'get_endidx', get_endidx):
+                # Run the readline tab-completion function with readline mocks in place get None
+                completion = cmd2_app.complete(text, state)
+    assert completion is None
+
+def test_complete_empty_arg(cmd2_app):
+    text = ''
+    line = 'help '
+    state = 0
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    def get_line():
+        return line
+
+    def get_begidx():
+        return begidx
+
+    def get_endidx():
+        return endidx
+
+    with mock.patch.object(readline, 'get_line_buffer', get_line):
+        with mock.patch.object(readline, 'get_begidx', get_begidx):
+            with mock.patch.object(readline, 'get_endidx', get_endidx):
+                # Run the readline tab-completion function with readline mocks in place
+                completion = cmd2_app.complete(text, state)
+
+    assert completion == cmd2_app.complete_help(text, line, begidx, endidx)[0]
+
+def test_complete_bogus_command(cmd2_app):
+    text = ''
+    line = 'fizbuzz '
+    state = 0
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    def get_line():
+        return line
+
+    def get_begidx():
+        return begidx
+
+    def get_endidx():
+        return endidx
+
+    with mock.patch.object(readline, 'get_line_buffer', get_line):
+        with mock.patch.object(readline, 'get_begidx', get_begidx):
+            with mock.patch.object(readline, 'get_endidx', get_endidx):
+                # Run the readline tab-completion function with readline mocks in place
+                completion = cmd2_app.complete(text, state)
+
+    assert completion is None
+
 def test_cmd2_command_completion_is_case_insensitive_by_default(cmd2_app):
     text = 'HE'
     line = 'HE'
@@ -323,3 +420,123 @@ def test_parseline_expands_shortcuts(cmd2_app):
     assert command == 'shell'
     assert args == 'cat foobar.txt'
     assert line.replace('!', 'shell ') == out_line
+
+
+class SubcommandsExample(cmd2.Cmd):
+    """ Example cmd2 application where we a base command which has a couple subcommands."""
+
+    def __init__(self):
+        cmd2.Cmd.__init__(self)
+
+    # sub-command functions for the base command
+    def base_foo(self, args):
+        """foo subcommand of base command"""
+        self.poutput(args.x * args.y)
+
+    def base_bar(self, args):
+        """bar sucommand of base command"""
+        self.poutput('((%s))' % args.z)
+
+    # create the top-level parser for the base command
+    base_parser = argparse.ArgumentParser(prog='base')
+    base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
+
+    # create the parser for the "foo" sub-command
+    parser_foo = base_subparsers.add_parser('foo', help='foo help')
+    parser_foo.add_argument('-x', type=int, default=1, help='integer')
+    parser_foo.add_argument('y', type=float, help='float')
+    parser_foo.set_defaults(func=base_foo)
+
+    # create the parser for the "bar" sub-command
+    parser_bar = base_subparsers.add_parser('bar', help='bar help')
+    parser_bar.add_argument('z', help='string')
+    parser_bar.set_defaults(func=base_bar)
+
+    # Create a list of subcommand names, which is used to enable tab-completion of sub-commands
+    subcommands = ['foo', 'bar']
+
+    @cmd2.with_argparser(base_parser, subcommands)
+    def do_base(self, args):
+        """Base command help"""
+        try:
+            # Call whatever sub-command function was selected
+            args.func(self, args)
+        except AttributeError:
+            # No sub-command was provided, so as called
+            self.do_help('base')
+
+
+@pytest.fixture
+def sc_app():
+    app = SubcommandsExample()
+    return app
+
+
+def test_cmd2_subcommand_completion_single_end(sc_app):
+    text = 'f'
+    line = 'base f'
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    # It is at end of line, so extra space is present
+    assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo ']
+
+def test_cmd2_subcommand_completion_single_mid(sc_app):
+    text = 'f'
+    line = 'base f'
+    endidx = len(line) - 1
+    begidx = endidx - len(text)
+
+    # It is at end of line, so extra space is present
+    assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo']
+
+def test_cmd2_subcommand_completion_multiple(sc_app):
+    text = ''
+    line = 'base '
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    # It is at end of line, so extra space is present
+    assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo', 'bar']
+
+def test_cmd2_subcommand_completion_nomatch(sc_app):
+    text = 'z'
+    line = 'base z'
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    # It is at end of line, so extra space is present
+    assert sc_app.complete_subcommand(text, line, begidx, endidx) == []
+
+def test_cmd2_subcommand_completion_after_subcommand(sc_app):
+    text = 'f'
+    line = 'base foo f'
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    # It is at end of line, so extra space is present
+    assert sc_app.complete_subcommand(text, line, begidx, endidx) == []
+
+
+def test_complete_subcommand_single_end(sc_app):
+    text = 'f'
+    line = 'base f'
+    endidx = len(line)
+    begidx = endidx - len(text)
+    state = 0
+
+    def get_line():
+        return line
+
+    def get_begidx():
+        return begidx
+
+    def get_endidx():
+        return endidx
+
+    with mock.patch.object(readline, 'get_line_buffer', get_line):
+        with mock.patch.object(readline, 'get_begidx', get_begidx):
+            with mock.patch.object(readline, 'get_endidx', get_endidx):
+                # Run the readline tab-completion function with readline mocks in place
+                completion = sc_app.complete(text, state)
+    assert completion == 'foo '