Repository: python-cmd2/cmd2
Pull request #550 (2018-09-27 05:41:55)

Added macros
Macros are similar to aliases, but can take arguments when called. These are useful when you need to run a complex command frequently using different arguments.

Arguments are expressed when creating a macro using {#} notation where {1} means the first argument.

The following creates a macro called my_macro that expects two arguments:

> macro create my_macro make_dinner -meat {1} -veggie {2}

When the macro is called, the provided arguments are resolved and the assembled command is run. For example:

> my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli

Other unrelated changes:
* ``unalias`` command deleted
* ``alias`` command now uses *create*, *delete*, and *list* sub-commands


Patch:
diff --git a/.gitignore b/.gitignore
index 9b5a5ba8..8acd2d4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,6 @@ htmlcov
 # mypy plugin for PyCharm
 dmypy.json
 dmypy.sock
+
+# cmd2 history file used in main.py
+cmd2_history.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3015b793..bad61734 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,13 +21,18 @@
             if the output is a pipe or a file the escape sequences are stripped
             * Always - output methods **never** strip ANSI escape sequences, regardless of the output destination
             * Never - output methods strip all ANSI escape sequences
+    * Added ``macro`` command to create macros, which are similar to aliases, but can take arguments when called
+    * ``alias`` is now an argparse command with subcommands to create, list, and delete aliases 
 * Deprecations
-    * Deprecated the builtin ``cmd2`` suport for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes``
-* Deletions
+    * Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes``
+* Deletions (potentially breaking changes)
     * The ``preparse``, ``postparsing_precmd``, and ``postparsing_postcmd`` methods *deprecated* in the previous release 
     have been deleted
         * The new application lifecycle hook system allows for registration of callbacks to be called at various points
         in the lifecycle and is more powerful and flexible than the previous system
+    * ``alias`` is now a command with subcommands to create, list, and delete aliases. Therefore its syntax
+      has changed. All current alias commands in startup scripts or transcripts will break with this release.
+    * `unalias` was deleted since ``alias delete`` replaced it
     
 ## 0.9.4 (August 21, 2018)
 * Bug Fixes
@@ -139,7 +144,7 @@
     * Fixed ``AttributeError`` on Windows when running a ``select`` command cause by **pyreadline** not implementing ``remove_history_item``
 * Enhancements
     * Added warning about **libedit** variant of **readline** not being supported on macOS
-    * Added tab-completion of alias names in value filed of **alias** command
+    * Added tab-completion of alias names in value field of **alias** command
     * Enhanced the ``py`` console in the following ways
         * Added tab completion of Python identifiers instead of **cmd2** commands
         * Separated the ``py`` console history from the **cmd2** history
diff --git a/README.md b/README.md
index dc7bd9a8..efb9f122 100755
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ Main Features
 - Multi-line commands
 - Special-character command shortcuts (beyond cmd's `?` and `!`)
 - Command aliasing similar to bash `alias` command
+- Macros, which are similar to aliases, but can take arguments when called
 - Ability to load commands at startup from an initialization script
 - Settable environment parameters
 - Parsing commands with arguments using `argparse`, including support for sub-commands
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index dc9baf7a..ad2c520b 100755
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -750,7 +750,7 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
 
             # build full usage string
             format = self._format_actions_usage
-            action_usage = format(positionals + required_options + optionals, groups)
+            action_usage = format(required_options + optionals + positionals, groups)
             usage = ' '.join([s for s in [prog, action_usage] if s])
 
             # wrap the usage parts if it's too long
@@ -761,15 +761,15 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
 
                 # break usage into wrappable parts
                 part_regexp = r'\(.*?\)+|\[.*?\]+|\S+'
+                req_usage = format(required_options, groups)
                 opt_usage = format(optionals, groups)
                 pos_usage = format(positionals, groups)
-                req_usage = format(required_options, groups)
+                req_parts = _re.findall(part_regexp, req_usage)
                 opt_parts = _re.findall(part_regexp, opt_usage)
                 pos_parts = _re.findall(part_regexp, pos_usage)
-                req_parts = _re.findall(part_regexp, req_usage)
+                assert ' '.join(req_parts) == req_usage
                 assert ' '.join(opt_parts) == opt_usage
                 assert ' '.join(pos_parts) == pos_usage
-                assert ' '.join(req_parts) == req_usage
 
                 # End cmd2 customization
 
@@ -799,13 +799,15 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
                 if len(prefix) + len(prog) <= 0.75 * text_width:
                     indent = ' ' * (len(prefix) + len(prog) + 1)
                     # Begin cmd2 customization
-                    if opt_parts:
-                        lines = get_lines([prog] + pos_parts, indent, prefix)
-                        lines.extend(get_lines(req_parts, indent))
+                    if req_parts:
+                        lines = get_lines([prog] + req_parts, indent, prefix)
                         lines.extend(get_lines(opt_parts, indent))
+                        lines.extend(get_lines(pos_parts, indent))
+                    elif opt_parts:
+                        lines = get_lines([prog] + opt_parts, indent, prefix)
+                        lines.extend(get_lines(pos_parts, indent))
                     elif pos_parts:
                         lines = get_lines([prog] + pos_parts, indent, prefix)
-                        lines.extend(get_lines(req_parts, indent))
                     else:
                         lines = [prog]
                     # End cmd2 customization
@@ -814,13 +816,13 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
                 else:
                     indent = ' ' * len(prefix)
                     # Begin cmd2 customization
-                    parts = pos_parts + req_parts + opt_parts
+                    parts = req_parts + opt_parts + pos_parts
                     lines = get_lines(parts, indent)
                     if len(lines) > 1:
                         lines = []
-                        lines.extend(get_lines(pos_parts, indent))
                         lines.extend(get_lines(req_parts, indent))
                         lines.extend(get_lines(opt_parts, indent))
+                        lines.extend(get_lines(pos_parts, indent))
                     # End cmd2 customization
                     lines = [prog] + lines
 
@@ -889,6 +891,9 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
             result = super()._format_args(action, default_metavar)
         return result
 
+    def format_help(self):
+        return super().format_help() + '\n'
+
 
 # noinspection PyCompatibility
 class ACArgumentParser(argparse.ArgumentParser):
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 64702577..d029aa6c 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -48,7 +48,7 @@ from . import utils
 from . import plugin
 from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES
 from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
-from .parsing import StatementParser, Statement
+from .parsing import StatementParser, Statement, Macro, MacroArg
 
 # Set up readline
 from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt
@@ -105,9 +105,9 @@ except ImportError:
 
 # Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout
 if sys.version_info < (3, 5):
-    from contextlib2 import redirect_stdout, redirect_stderr
+    from contextlib2 import redirect_stdout
 else:
-    from contextlib import redirect_stdout, redirect_stderr
+    from contextlib import redirect_stdout
 
 # Detect whether IPython is installed to determine if the built-in "ipy" command should be included
 ipython_available = True
@@ -122,6 +122,9 @@ except ImportError:  # pragma: no cover
 HELP_CATEGORY = 'help_category'
 HELP_SUMMARY = 'help_summary'
 
+# All command functions start with this
+COMMAND_PREFIX = 'do_'
+
 
 def categorize(func: Union[Callable, Iterable], category: str) -> None:
     """Categorize a function.
@@ -138,19 +141,21 @@ def categorize(func: Union[Callable, Iterable], category: str) -> None:
         setattr(func, HELP_CATEGORY, category)
 
 
-def parse_quoted_string(cmdline: str) -> List[str]:
-    """Parse a quoted string into a list of arguments."""
-    if isinstance(cmdline, list):
+def parse_quoted_string(string: str, preserve_quotes: bool) -> List[str]:
+    """
+    Parse a quoted string into a list of arguments
+    :param string: the string being parsed
+    :param preserve_quotes: if True, then quotes will not be stripped
+    """
+    if isinstance(string, list):
         # arguments are already a list, return the list we were passed
-        lexed_arglist = cmdline
+        lexed_arglist = string
     else:
         # Use shlex to split the command line into a list of arguments based on shell rules
-        lexed_arglist = shlex.split(cmdline, posix=False)
-        # strip off outer quotes for convenience
-        temp_arglist = []
-        for arg in lexed_arglist:
-            temp_arglist.append(utils.strip_quotes(arg))
-        lexed_arglist = temp_arglist
+        lexed_arglist = shlex.split(string, posix=False)
+
+        if not preserve_quotes:
+            lexed_arglist = [utils.strip_quotes(arg) for arg in lexed_arglist]
     return lexed_arglist
 
 
@@ -162,7 +167,7 @@ def with_category(category: str) -> Callable:
     return cat_decorator
 
 
-def with_argument_list(func: Callable) -> Callable:
+def with_argument_list(func: Callable, preserve_quotes: bool=False) -> Callable:
     """A decorator to alter the arguments passed to a do_* cmd2
     method. Default passes a string of whatever the user typed.
     With this decorator, the decorated method will receive a list
@@ -171,18 +176,19 @@ def with_argument_list(func: Callable) -> Callable:
 
     @functools.wraps(func)
     def cmd_wrapper(self, cmdline):
-        lexed_arglist = parse_quoted_string(cmdline)
+        lexed_arglist = parse_quoted_string(cmdline, preserve_quotes)
         return func(self, lexed_arglist)
 
     cmd_wrapper.__doc__ = func.__doc__
     return cmd_wrapper
 
 
-def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Callable:
+def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable:
     """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: given instance of ArgumentParser
+    :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes
     :return: function that gets passed parsed args and a list of unknown args
     """
     import functools
@@ -191,7 +197,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla
     def arg_decorator(func: Callable):
         @functools.wraps(func)
         def cmd_wrapper(instance, cmdline):
-            lexed_arglist = parse_quoted_string(cmdline)
+            lexed_arglist = parse_quoted_string(cmdline, preserve_quotes)
             try:
                 args, unknown = argparser.parse_known_args(lexed_arglist)
             except SystemExit:
@@ -220,11 +226,12 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla
     return arg_decorator
 
 
-def with_argparser(argparser: argparse.ArgumentParser) -> Callable:
+def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable:
     """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
     with the given instance of argparse.ArgumentParser.
 
     :param argparser: given instance of ArgumentParser
+    :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes
     :return: function that gets passed parsed args
     """
     import functools
@@ -233,7 +240,7 @@ def with_argparser(argparser: argparse.ArgumentParser) -> Callable:
     def arg_decorator(func: Callable):
         @functools.wraps(func)
         def cmd_wrapper(instance, cmdline):
-            lexed_arglist = parse_quoted_string(cmdline)
+            lexed_arglist = parse_quoted_string(cmdline, preserve_quotes)
             try:
                 args = argparser.parse_args(lexed_arglist)
             except SystemExit:
@@ -385,8 +392,9 @@ class Cmd(cmd.Cmd):
         # Commands to exclude from the history command
         self.exclude_from_history = '''history edit eof eos'''.split()
 
-        # Command aliases
+        # Command aliases and macros
         self.aliases = dict()
+        self.macros = dict()
 
         self._finalize_app_parameters()
 
@@ -395,7 +403,7 @@ class Cmd(cmd.Cmd):
         self.pystate = {}
         self.py_history = []
         self.pyscript_name = 'app'
-        self.keywords = self.reserved_words + [fname[3:] for fname in dir(self) if fname.startswith('do_')]
+        self.keywords = self.reserved_words + self.get_all_commands()
         self.statement_parser = StatementParser(
             allow_redirection=self.allow_redirection,
             terminators=self.terminators,
@@ -1466,18 +1474,21 @@ class Cmd(cmd.Cmd):
                 # Check if a valid command was entered
                 if command in self.get_all_commands():
                     # Get the completer function for this command
-                    try:
-                        compfunc = getattr(self, 'complete_' + command)
-                    except AttributeError:
+                    compfunc = getattr(self, 'complete_' + command, None)
+
+                    if compfunc is None:
                         # There's no completer function, next see if the command uses argparser
-                        try:
-                            cmd_func = getattr(self, 'do_' + command)
-                            argparser = getattr(cmd_func, 'argparser')
-                            # Command uses argparser, switch to the default argparse completer
-                            compfunc = functools.partial(self._autocomplete_default, argparser=argparser)
-                        except AttributeError:
+                        func = self.cmd_func(command)
+                        if func and hasattr(func, 'argparser'):
+                            compfunc = functools.partial(self._autocomplete_default,
+                                                         argparser=getattr(func, 'argparser'))
+                        else:
                             compfunc = self.completedefault
 
+                # Check if a macro was entered
+                elif command in self.macros:
+                    compfunc = self.path_complete
+
                 # A valid command was not entered
                 else:
                     # Check if this command should be run as a shell command
@@ -1545,11 +1556,9 @@ class Cmd(cmd.Cmd):
                             [shortcut_to_restore + match for match in self.completion_matches]
 
             else:
-                # Complete token against aliases and command names
-                alias_names = set(self.aliases.keys())
-                visible_commands = set(self.get_visible_commands())
-                strs_to_match = list(alias_names | visible_commands)
-                self.completion_matches = self.basic_complete(text, line, begidx, endidx, strs_to_match)
+                # Complete token against anything a user can run
+                self.completion_matches = self.basic_complete(text, line, begidx, endidx,
+                                                              self.get_commands_aliases_and_macros_for_completion())
 
             # Handle single result
             if len(self.completion_matches) == 1:
@@ -1589,8 +1598,8 @@ class Cmd(cmd.Cmd):
 
     def get_all_commands(self) -> List[str]:
         """Returns a list of all commands."""
-        return [name[3:] for name in self.get_names()
-                if name.startswith('do_') and callable(getattr(self, name))]
+        return [name[len(COMMAND_PREFIX):] for name in self.get_names()
+                if name.startswith(COMMAND_PREFIX) and callable(getattr(self, name))]
 
     def get_visible_commands(self) -> List[str]:
         """Returns a list of commands that have not been hidden."""
@@ -1603,6 +1612,21 @@ class Cmd(cmd.Cmd):
 
         return commands
 
+    def get_alias_names(self) -> List[str]:
+        """Return a list of alias names."""
+        return list(self.aliases)
+
+    def get_macro_names(self) -> List[str]:
+        """Return a list of macro names."""
+        return list(self.macros)
+
+    def get_commands_aliases_and_macros_for_completion(self) -> List[str]:
+        """Return a list of visible commands, aliases, and macros for tab completion"""
+        visible_commands = set(self.get_visible_commands())
+        alias_names = set(self.get_alias_names())
+        macro_names = set(self.get_macro_names())
+        return list(visible_commands | alias_names | macro_names)
+
     def get_help_topics(self) -> List[str]:
         """ Returns a list of help topics """
         return [name[5:] for name in self.get_names()
@@ -1641,13 +1665,10 @@ class Cmd(cmd.Cmd):
 
         # check if the command uses argparser
         elif index >= subcmd_index:
-            try:
-                cmd_func = getattr(self, 'do_' + tokens[cmd_index])
-                parser = getattr(cmd_func, 'argparser')
-                completer = AutoCompleter(parser, cmd2_app=self)
+            func = self.cmd_func(tokens[cmd_index])
+            if func and hasattr(func, 'argparser'):
+                completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self)
                 matches = completer.complete_command_help(tokens[1:], text, line, begidx, endidx)
-            except AttributeError:
-                pass
 
         return matches
 
@@ -1981,19 +2002,25 @@ class Cmd(cmd.Cmd):
 
         self.redirecting = False
 
-    def _func_named(self, arg: str) -> str:
-        """Gets the method name associated with a given command.
+    def cmd_func(self, command: str) -> Optional[Callable]:
+        """
+        Get the function for a command
+        :param command: the name of the command
+        """
+        func_name = self.cmd_func_name(command)
+        if func_name:
+            return getattr(self, func_name)
 
-        :param arg: command to look up method name which implements it
+    def cmd_func_name(self, command: str) -> str:
+        """Get the method name associated with a given command.
+
+        :param command: command to look up method name which implements it
         :return: method name which implements the given command
         """
-        result = None
-        target = 'do_' + arg
-        if target in dir(self):
-            result = target
-        return result
+        target = COMMAND_PREFIX + command
+        return target if callable(getattr(self, target, None)) else ''
 
-    def onecmd(self, statement: Union[Statement, str]) -> Optional[bool]:
+    def onecmd(self, statement: Union[Statement, str]) -> bool:
         """ This executes the actual do_* method for a command.
 
         If the command provided doesn't exist, then it executes _default() instead.
@@ -2006,24 +2033,60 @@ class Cmd(cmd.Cmd):
         if not isinstance(statement, Statement):
             statement = self._complete_statement(statement)
 
-        funcname = self._func_named(statement.command)
-        if not funcname:
-            self.default(statement)
-            return
+        # Check if this is a macro
+        if statement.command in self.macros:
+            stop = self._run_macro(statement)
+        else:
+            func = self.cmd_func(statement.command)
+            if func:
+                stop = func(statement)
 
-        # Since we have a valid command store it in the history
-        if statement.command not in self.exclude_from_history:
-            self.history.append(statement.raw)
+                # Since we have a valid command store it in the history
+                if statement.command not in self.exclude_from_history:
+                    self.history.append(statement.raw)
 
-        try:
-            func = getattr(self, funcname)
-        except AttributeError:
-            self.default(statement)
-            return
+            else:
+                self.default(statement)
+                stop = False
 
-        stop = func(statement)
         return stop
 
+    def _run_macro(self, statement: Statement) -> bool:
+        """
+        Resolve a macro and run the resulting string
+
+        :param statement: the parsed statement from the command line
+        :return: a flag indicating whether the interpretation of commands should stop
+        """
+        if statement.command not in self.macros.keys():
+            raise KeyError('{} is not a macro'.format(statement.command))
+
+        macro = self.macros[statement.command]
+
+        # For macros, every argument must be provided and there can be no extra arguments.
+        if len(statement.arg_list) != macro.required_arg_count:
+            self.perror("The macro '{}' expects {} argument(s)".format(statement.command, macro.required_arg_count),
+                        traceback_war=False)
+            return False
+
+        # Resolve the arguments in reverse
+        resolved = macro.value
+        reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
+
+        for arg in reverse_arg_list:
+            if arg.is_escaped:
+                to_replace = '{{' + arg.number_str + '}}'
+                replacement = '{' + arg.number_str + '}'
+            else:
+                to_replace = '{' + arg.number_str + '}'
+                replacement = statement.argv[int(arg.number_str)]
+
+            parts = resolved.rsplit(to_replace, maxsplit=1)
+            resolved = parts[0] + replacement + parts[1]
+
+        # Run the resolved command
+        return self.onecmd_plus_hooks(resolved)
+
     def default(self, statement: Statement) -> None:
         """Executed when the command given isn't a recognized command implemented by a do_* method.
 
@@ -2176,120 +2239,353 @@ class Cmd(cmd.Cmd):
 
             return stop
 
-    def do_alias(self, statement: Statement) -> None:
-        """Define or display aliases
+    # -----  Alias subcommand functions -----
 
-    Usage: alias [name] | [<name> <value>]
-    Where:
-        name - name of the alias being looked up, added, or replaced
-        value - what the alias will be resolved to (if adding or replacing)
-                this can contain spaces and does not need to be quoted
+    def alias_create(self, args: argparse.Namespace):
+        """ Creates or overwrites an alias """
+
+        # Validate the alias name
+        args.name = utils.strip_quotes(args.name)
+        valid, errmsg = self.statement_parser.is_valid_command(args.name)
+        if not valid:
+            errmsg = "Invalid alias name: {}".format(errmsg)
+            self.perror(errmsg, traceback_war=False)
+            return
 
-    Without arguments, 'alias' prints a list of all aliases in a reusable form which
-    can be outputted to a startup_script to preserve aliases across sessions.
+        if args.name in self.macros:
+            errmsg = "Alias cannot have the same name as a macro"
+            self.perror(errmsg, traceback_war=False)
+            return
 
-    With one argument, 'alias' shows the value of the specified alias.
-    Example: alias ls  (Prints the value of the alias called 'ls' if it exists)
+        utils.unquote_redirection_tokens(args.command_args)
 
-    With two or more arguments, 'alias' creates or replaces an alias.
+        # Build the alias value string
+        value = args.command
+        if args.command_args:
+            value += ' ' + ' '.join(args.command_args)
 
-    Example: alias ls !ls -lF
+        # Set the alias
+        result = "overwritten" if args.name in self.aliases else "created"
+        self.aliases[args.name] = value
+        self.poutput("Alias '{}' {}".format(args.name, result))
 
-    If you want to use redirection or pipes in the alias, then quote them to prevent
-    the alias command itself from being redirected
+    def alias_delete(self, args: argparse.Namespace):
+        """ Deletes aliases """
+        if args.all:
+            self.aliases.clear()
+            self.poutput("All aliases deleted")
+        elif not args.name:
+            self.do_help(['alias', 'delete'])
+        else:
+            # Get rid of duplicates and strip quotes since the argparse decorator for do_alias() preserves them
+            aliases_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)]
 
-    Examples:
-        alias save_results print_results ">" out.txt
-        alias save_results print_results '>' out.txt
-"""
-        # Get alias arguments as a list with quotes preserved
-        alias_arg_list = statement.arg_list
+            for cur_name in aliases_to_delete:
+                if cur_name in self.aliases:
+                    del self.aliases[cur_name]
+                    self.poutput("Alias '{}' deleted".format(cur_name))
+                else:
+                    self.perror("Alias '{}' does not exist".format(cur_name), traceback_war=False)
 
-        # If no args were given, then print a list of current aliases
-        if not alias_arg_list:
-            sorted_aliases = utils.alphabetical_sort(list(self.aliases))
+    def alias_list(self, args: argparse.Namespace):
+        """ Lists some or all aliases """
+        if args.name:
+            # Get rid of duplicates and strip quotes since the argparse decorator for do_alias() preserves them
+            names_to_view = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)]
+
+            for cur_name in names_to_view:
+                if cur_name in self.aliases:
+                    self.poutput("alias create {} {}".format(cur_name, self.aliases[cur_name]))
+                else:
+                    self.perror("Alias '{}' not found".format(cur_name), traceback_war=False)
+        else:
+            sorted_aliases = utils.alphabetical_sort(self.aliases)
             for cur_alias in sorted_aliases:
-                self.poutput("alias {} {}".format(cur_alias, self.aliases[cur_alias]))
+                self.poutput("alias create {} {}".format(cur_alias, self.aliases[cur_alias]))
+
+    # Top-level parser for alias
+    alias_description = ("Manage aliases\n"
+                         "\n"
+                         "An alias is a command that enables replacement of a word by another string.")
+    alias_epilog = ("See also:\n"
+                    "  macro")
+    alias_parser = ACArgumentParser(description=alias_description, epilog=alias_epilog, prog='alias')
+
+    # Add subcommands to alias
+    alias_subparsers = alias_parser.add_subparsers()
+
+    # alias -> create
+    alias_create_help = "create or overwrite an alias"
+    alias_create_description = "Create or overwrite an alias"
+
+    alias_create_epilog = ("Notes:\n"
+                           "  If you want to use redirection or pipes in the alias, then quote them to\n"
+                           "  prevent the 'alias create' command from being redirected.\n"
+                           "\n"
+                           "  Since aliases are resolved during parsing, tab completion will function as it\n"
+                           "  would for the actual command the alias resolves to.\n"
+                           "\n"
+                           "Examples:\n"
+                           "  alias ls !ls -lF\n"
+                           "  alias create show_log !cat \"log file.txt\"\n"
+                           "  alias create save_results print_results \">\" out.txt\n")
+
+    alias_create_parser = alias_subparsers.add_parser('create', help=alias_create_help,
+                                                      description=alias_create_description,
+                                                      epilog=alias_create_epilog)
+    alias_create_parser.add_argument('name', help='name of this alias')
+    setattr(alias_create_parser.add_argument('command', help='what the alias resolves to'),
+            ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion)
+    setattr(alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER,
+                                             help='arguments being passed to command'),
+            ACTION_ARG_CHOICES, ('path_complete',))
+    alias_create_parser.set_defaults(func=alias_create)
+
+    # alias -> delete
+    alias_delete_help = "delete aliases"
+    alias_delete_description = "Delete specified aliases or all aliases if --all is used"
+    alias_delete_parser = alias_subparsers.add_parser('delete', help=alias_delete_help,
+                                                      description=alias_delete_description)
+    setattr(alias_delete_parser.add_argument('name', nargs='*', help='alias to delete'),
+            ACTION_ARG_CHOICES, get_alias_names)
+    alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases")
+    alias_delete_parser.set_defaults(func=alias_delete)
+
+    # alias -> list
+    alias_list_help = "list aliases"
+    alias_list_description = ("List specified aliases in a reusable form that can be saved to a startup script\n"
+                              "to preserve aliases across sessions\n"
+                              "\n"
+                              "Without arguments, all aliases will be listed.")
+
+    alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help,
+                                                    description=alias_list_description)
+    setattr(alias_list_parser.add_argument('name', nargs="*", help='alias to list'),
+            ACTION_ARG_CHOICES, get_alias_names)
+    alias_list_parser.set_defaults(func=alias_list)
+
+    # Preserve quotes since we are passing strings to other commands
+    @with_argparser(alias_parser, preserve_quotes=True)
+    def do_alias(self, args: argparse.Namespace):
+        """Manage aliases"""
+        func = getattr(args, 'func', None)
+        if func is not None:
+            # Call whatever subcommand function was selected
+            func(self, args)
+        else:
+            # No subcommand was provided, so call help
+            self.do_help(['alias'])
+
+    # -----  Macro subcommand functions -----
+
+    def macro_create(self, args: argparse.Namespace):
+        """ Creates or overwrites a macro """
+
+        # Validate the macro name
+        args.name = utils.strip_quotes(args.name)
+        valid, errmsg = self.statement_parser.is_valid_command(args.name)
+        if not valid:
+            errmsg = "Invalid macro name: {}".format(errmsg)
+            self.perror(errmsg, traceback_war=False)
             return
 
-        # Get the alias name
-        name = alias_arg_list[0]
+        if args.name in self.get_all_commands():
+            errmsg = "Macro cannot have the same name as a command"
+            self.perror(errmsg, traceback_war=False)
+            return
 
-        # The user is looking up an alias
-        if len(alias_arg_list) == 1:
-            if name in self.aliases:
-                self.poutput("alias {} {}".format(name, self.aliases[name]))
-            else:
-                self.perror("Alias {!r} not found".format(name), traceback_war=False)
+        if args.name in self.aliases:
+            errmsg = "Macro cannot have the same name as an alias"
+            self.perror(errmsg, traceback_war=False)
+            return
 
-        # The user is creating an alias
-        else:
-            # Unquote redirection and pipes
-            index = 1
-            while index < len(alias_arg_list):
-                unquoted_arg = utils.strip_quotes(alias_arg_list[index])
-                if unquoted_arg in constants.REDIRECTION_TOKENS:
-                    alias_arg_list[index] = unquoted_arg
-                index += 1
-
-            # Build the alias value string
-            value = ' '.join(alias_arg_list[1:])
-
-            # Validate the alias to ensure it doesn't include weird characters
-            # like terminators, output redirection, or whitespace
-            valid, invalidchars = self.statement_parser.is_valid_command(name)
-            if valid:
-                # Set the alias
-                self.aliases[name] = value
-                self.poutput("Alias {!r} created".format(name))
-            else:
-                errmsg = "Aliases can not contain: {}".format(invalidchars)
-                self.perror(errmsg, traceback_war=False)
+        utils.unquote_redirection_tokens(args.command_args)
 
-    def complete_alias(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
-        """ Tab completion for alias """
-        alias_names = set(self.aliases.keys())
-        visible_commands = set(self.get_visible_commands())
+        # Build the macro value string
+        value = args.command
+        if args.command_args:
+            value += ' ' + ' '.join(args.command_args)
 
-        index_dict = \
-            {
-                1: alias_names,
-                2: list(alias_names | visible_commands)
-            }
-        return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete)
+        # Find all normal arguments
+        arg_list = []
+        normal_matches = re.finditer(MacroArg.macro_normal_arg_pattern, value)
+        max_arg_num = 0
+        arg_nums = set()
 
-    @with_argument_list
-    def do_unalias(self, arglist: List[str]) -> None:
-        """Unsets aliases
+        while True:
+            try:
+                cur_match = normal_matches.__next__()
 
-    Usage: unalias [-a] name [name ...]
-    Where:
-        name - name of the alias being unset
+                # Get the number string between the braces
+                cur_num_str = (re.findall(MacroArg.digit_pattern, cur_match.group())[0])
+                cur_num = int(cur_num_str)
+                if cur_num < 1:
+                    self.perror("Argument numbers must be greater than 0", traceback_war=False)
+                    return
 
-    Options:
-        -a     remove all alias definitions
-"""
-        if not arglist:
-            self.do_help(['unalias'])
+                arg_nums.add(cur_num)
+                if cur_num > max_arg_num:
+                    max_arg_num = cur_num
 
-        if '-a' in arglist:
-            self.aliases.clear()
-            self.poutput("All aliases cleared")
+                arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=False))
 
+            except StopIteration:
+                break
+
+        # Make sure the argument numbers are continuous
+        if len(arg_nums) != max_arg_num:
+            self.perror("Not all numbers between 1 and {} are present "
+                        "in the argument placeholders".format(max_arg_num), traceback_war=False)
+            return
+
+        # Find all escaped arguments
+        escaped_matches = re.finditer(MacroArg.macro_escaped_arg_pattern, value)
+
+        while True:
+            try:
+                cur_match = escaped_matches.__next__()
+
+                # Get the number string between the braces
+                cur_num_str = re.findall(MacroArg.digit_pattern, cur_match.group())[0]
+
+                arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=True))
+            except StopIteration:
+                break
+
+        # Set the macro
+        result = "overwritten" if args.name in self.macros else "created"
+        self.macros[args.name] = Macro(name=args.name, value=value, required_arg_count=max_arg_num, arg_list=arg_list)
+        self.poutput("Macro '{}' {}".format(args.name, result))
+
+    def macro_delete(self, args: argparse.Namespace):
+        """ Deletes macros """
+        if args.all:
+            self.macros.clear()
+            self.poutput("All macros deleted")
+        elif not args.name:
+            self.do_help(['macro', 'delete'])
         else:
-            # Get rid of duplicates
-            arglist = utils.remove_duplicates(arglist)
+            # Get rid of duplicates and strip quotes since the argparse decorator for do_macro() preserves them
+            macros_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)]
 
-            for cur_arg in arglist:
-                if cur_arg in self.aliases:
-                    del self.aliases[cur_arg]
-                    self.poutput("Alias {!r} cleared".format(cur_arg))
+            for cur_name in macros_to_delete:
+                if cur_name in self.macros:
+                    del self.macros[cur_name]
+                    self.poutput("Macro '{}' deleted".format(cur_name))
                 else:
-                    self.perror("Alias {!r} does not exist".format(cur_arg), traceback_war=False)
+                    self.perror("Macro '{}' does not exist".format(cur_name), traceback_war=False)
 
-    def complete_unalias(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
-        """ Tab completion for unalias """
-        return self.basic_complete(text, line, begidx, endidx, self.aliases)
+    def macro_list(self, args: argparse.Namespace):
+        """ Lists some or all macros """
+        if args.name:
+            # Get rid of duplicates and strip quotes since the argparse decorator for do_macro() preserves them
+            names_to_view = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)]
+
+            for cur_name in names_to_view:
+                if cur_name in self.macros:
+                    self.poutput("macro create {} {}".format(cur_name, self.macros[cur_name].value))
+                else:
+                    self.perror("Macro '{}' not found".format(cur_name), traceback_war=False)
+        else:
+            sorted_macros = utils.alphabetical_sort(self.macros)
+            for cur_macro in sorted_macros:
+                self.poutput("macro create {} {}".format(cur_macro, self.macros[cur_macro].value))
+
+    # Top-level parser for macro
+    macro_description = ("Manage macros\n"
+                         "\n"
+                         "A macro is similar to an alias, but it can take arguments when called.")
+    macro_epilog = ("See also:\n"
+                    "  alias")
+    macro_parser = ACArgumentParser(description=macro_description, epilog=macro_epilog, prog='macro')
+
+    # Add subcommands to macro
+    macro_subparsers = macro_parser.add_subparsers()
+
+    # macro -> create
+    macro_create_help = "create or overwrite a macro"
+    macro_create_description = "Create or overwrite a macro"
+
+    macro_create_epilog = ("A macro is similar to an alias, but it can take arguments when called.\n"
+                           "Arguments are expressed when creating a macro using {#} notation where {1}\n"
+                           "means the first argument.\n"
+                           "\n"
+                           "The following creates a macro called my_macro that expects two arguments:\n"
+                           "\n"
+                           "  macro create my_macro make_dinner -meat {1} -veggie {2}\n"
+                           "\n"
+                           "When the macro is called, the provided arguments are resolved and the assembled\n"
+                           "command is run. For example:\n"
+                           "\n"
+                           "  my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli\n"
+                           "\n"
+                           "Notes:\n"
+                           "  To use the literal string {1} in your command, escape it this way: {{1}}.\n"
+                           "\n"
+                           "  An argument number can be repeated in a macro. In the following example the\n"
+                           "  first argument will populate both {1} instances.\n"
+                           "\n"
+                           "    macro create ft file_taxes -p {1} -q {2} -r {1}\n"
+                           "\n"
+                           "  To quote an argument in the resolved command, quote it during creation.\n"
+                           "\n"
+                           "    macro create backup !cp \"{1}\" \"{1}.orig\"\n"
+                           "\n"
+                           "  Be careful! Since macros can resolve into commands, aliases, and macros,\n"
+                           "  it is possible to create a macro that results in infinite recursion.\n"
+                           "\n"
+                           "  If you want to use redirection or pipes in the macro, then quote them as in\n"
+                           "  this example to prevent the 'macro create' command from being redirected.\n"
+                           "\n"
+                           "    macro create show_results print_results -type {1} \"|\" less\n"
+                           "\n"
+                           "  Because macros do not resolve until after parsing (hitting Enter), tab\n"
+                           "  completion will only complete paths.")
+
+    macro_create_parser = macro_subparsers.add_parser('create', help=macro_create_help,
+                                                      description=macro_create_description,
+                                                      epilog=macro_create_epilog)
+    macro_create_parser.add_argument('name', help='name of this macro')
+    setattr(macro_create_parser.add_argument('command', help='what the macro resolves to'),
+            ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion)
+    setattr(macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER,
+                                             help='arguments being passed to command'),
+            ACTION_ARG_CHOICES, ('path_complete',))
+    macro_create_parser.set_defaults(func=macro_create)
+
+    # macro -> delete
+    macro_delete_help = "delete macros"
+    macro_delete_description = "Delete specified macros or all macros if --all is used"
+    macro_delete_parser = macro_subparsers.add_parser('delete', help=macro_delete_help,
+                                                      description=macro_delete_description)
+    setattr(macro_delete_parser.add_argument('name', nargs='*', help='macro to delete'),
+            ACTION_ARG_CHOICES, get_macro_names)
+    macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros")
+    macro_delete_parser.set_defaults(func=macro_delete)
+
+    # macro -> list
+    macro_list_help = "list macros"
+    macro_list_description = ("List specified macros in a reusable form that can be saved to a startup script\n"
+                              "to preserve macros across sessions\n"
+                              "\n"
+                              "Without arguments, all macros will be listed.")
+
+    macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help, description=macro_list_description)
+    setattr(macro_list_parser.add_argument('name', nargs="*", help='macro to list'),
+            ACTION_ARG_CHOICES, get_macro_names)
+    macro_list_parser.set_defaults(func=macro_list)
+
+    # Preserve quotes since we are passing strings to other commands
+    @with_argparser(macro_parser, preserve_quotes=True)
+    def do_macro(self, args: argparse.Namespace):
+        """Manage macros"""
+        func = getattr(args, 'func', None)
+        if func is not None:
+            # Call whatever subcommand function was selected
+            func(self, args)
+        else:
+            # No subcommand was provided, so call help
+            self.do_help(['macro'])
 
     @with_argument_list
     def do_help(self, arglist: List[str]) -> None:
@@ -2299,20 +2595,13 @@ class Cmd(cmd.Cmd):
             self._help_menu(verbose)
         else:
             # Getting help for a specific command
-            funcname = self._func_named(arglist[0])
-            if funcname:
-                # Check to see if this function was decorated with an argparse ArgumentParser
-                func = getattr(self, funcname)
-                if hasattr(func, 'argparser'):
-                    completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self)
-
-                    self.poutput(completer.format_help(arglist))
-                else:
-                    # No special behavior needed, delegate to cmd base class do_help()
-                    cmd.Cmd.do_help(self, funcname[3:])
+            func = self.cmd_func(arglist[0])
+            if func and hasattr(func, 'argparser'):
+                completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self)
+                self.poutput(completer.format_help(arglist))
             else:
-                # This could be a help topic
-                cmd.Cmd.do_help(self, arglist[0])
+                # No special behavior needed, delegate to cmd base class do_help()
+                super().do_help(arglist[0])
 
     def _help_menu(self, verbose: bool=False) -> None:
         """Show a list of commands which help can be displayed for.
@@ -2328,11 +2617,12 @@ class Cmd(cmd.Cmd):
         cmds_cats = {}
 
         for command in visible_commands:
-            if command in help_topics or getattr(self, self._func_named(command)).__doc__:
+            func = self.cmd_func(command)
+            if command in help_topics or func.__doc__:
                 if command in help_topics:
                     help_topics.remove(command)
-                if hasattr(getattr(self, self._func_named(command)), HELP_CATEGORY):
-                    category = getattr(getattr(self, self._func_named(command)), HELP_CATEGORY)
+                if hasattr(func, HELP_CATEGORY):
+                    category = getattr(func, HELP_CATEGORY)
                     cmds_cats.setdefault(category, [])
                     cmds_cats[category].append(command)
                 else:
@@ -2385,12 +2675,13 @@ class Cmd(cmd.Cmd):
                         func = getattr(self, 'help_' + command)
                     except AttributeError:
                         # Couldn't find a help function
+                        func = self.cmd_func(command)
                         try:
                             # Now see if help_summary has been set
-                            doc = getattr(self, self._func_named(command)).help_summary
+                            doc = func.help_summary
                         except AttributeError:
                             # Last, try to directly access the function's doc-string
-                            doc = getattr(self, self._func_named(command)).__doc__
+                            doc = func.__doc__
                     else:
                         # we found the help function
                         result = io.StringIO()
@@ -2527,10 +2818,10 @@ class Cmd(cmd.Cmd):
         else:
             raise LookupError("Parameter '{}' not supported (type 'set' for list of parameters).".format(param))
 
-    set_description = "Sets a settable parameter or shows current settings of parameters.\n"
-    set_description += "\n"
-    set_description += "Accepts abbreviated parameter names so long as there is no ambiguity.\n"
-    set_description += "Call without arguments for a list of settable parameters with their values."
+    set_description = ("Sets a settable parameter or shows current settings of parameters.\n"
+                       "\n"
+                       "Accepts abbreviated parameter names so long as there is no ambiguity.\n"
+                       "Call without arguments for a list of settable parameters with their values.")
 
     set_parser = ACArgumentParser(description=set_description)
     set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well')
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index 1d22ccb8..e90eac43 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -13,6 +13,55 @@ from . import constants
 from . import utils
 
 
+@attr.s(frozen=True)
+class MacroArg:
+    """
+    Information used to replace or unescape arguments in a macro value when the macro is resolved
+    Normal argument syntax  : {5}
+    Escaped argument syntax: {{5}}
+    """
+    # The starting index of this argument in the macro value
+    start_index = attr.ib(validator=attr.validators.instance_of(int))
+
+    # The number string that appears between the braces
+    # This is a string instead of an int because we support unicode digits and must be able
+    # to reproduce this string later
+    number_str = attr.ib(validator=attr.validators.instance_of(str))
+
+    # Tells if this argument is escaped and therefore needs to be unescaped
+    is_escaped = attr.ib(validator=attr.validators.instance_of(bool))
+
+    # Pattern used to find normal argument
+    # Digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side
+    # Match strings like: {5}, {{{{{4}, {2}}}}}
+    macro_normal_arg_pattern = re.compile(r'(?<!{){\d+}|{\d+}(?!})')
+
+    # Pattern used to find escaped arguments
+    # Digits surrounded by 2 or more braces on both sides
+    # Match strings like: {{5}}, {{{{{4}}, {{2}}}}}
+    macro_escaped_arg_pattern = re.compile(r'{{2}\d+}{2}')
+
+    # Finds a string of digits
+    digit_pattern = re.compile(r'\d+')
+
+
+@attr.s(frozen=True)
+class Macro:
+    """Defines a cmd2 macro"""
+
+    # Name of the macro
+    name = attr.ib(validator=attr.validators.instance_of(str))
+
+    # The string the macro resolves to
+    value = attr.ib(validator=attr.validators.instance_of(str))
+
+    # The required number of args the user has to pass to this macro
+    required_arg_count = attr.ib(validator=attr.validators.instance_of(int))
+
+    # Used to fill in argument placeholders in the macro
+    arg_list = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list))
+
+
 @attr.s(frozen=True)
 class Statement(str):
     """String subclass with additional attributes to store the results of parsing.
@@ -247,23 +296,33 @@ class StatementParser:
         self._command_pattern = re.compile(expr)
 
     def is_valid_command(self, word: str) -> Tuple[bool, str]:
-        """Determine whether a word is a valid alias.
+        """Determine whether a word is a valid name for a command.
 
-        Aliases can not include redirection characters, whitespace,
-        or termination characters.
+        Commands can not include redirection characters, whitespace,
+        or termination characters. They also cannot start with a
+        shortcut.
 
-        If word is not a valid command, return False and a comma
-        separated string of characters that can not appear in a command.
+        If word is not a valid command, return False and error text
         This string is suitable for inclusion in an error message of your
         choice:
 
-        valid, invalidchars = statement_parser.is_valid_command('>')
+        valid, errmsg = statement_parser.is_valid_command('>')
         if not valid:
-            errmsg = "Aliases can not contain: {}".format(invalidchars)
+            errmsg = "Alias {}".format(errmsg)
         """
         valid = False
 
-        errmsg = 'whitespace, quotes, '
+        if not word:
+            return False, 'cannot be an empty string'
+
+        for (shortcut, _) in self.shortcuts:
+            if word.startswith(shortcut):
+                # Build an error string with all shortcuts listed
+                errmsg = 'cannot start with a shortcut: '
+                errmsg += ', '.join(shortcut for (shortcut, _) in self.shortcuts)
+                return False, errmsg
+
+        errmsg = 'cannot contain: whitespace, quotes, '
         errchars = []
         errchars.extend(constants.REDIRECTION_CHARS)
         errchars.extend(self.terminators)
diff --git a/cmd2/pyscript_bridge.py b/cmd2/pyscript_bridge.py
index d8fa5fb9..f03b530f 100644
--- a/cmd2/pyscript_bridge.py
+++ b/cmd2/pyscript_bridge.py
@@ -179,7 +179,10 @@ class ArgparseFunctor:
 
     def _run(self):
         # look up command function
-        func = getattr(self._cmd2_app, 'do_' + self._command_name)
+        func = self._cmd2_app.cmd_func(self._command_name)
+        if func is None:
+            raise AttributeError("{!r} object has no command called {!r}".format(self._cmd2_app.__class__.__name__,
+                                                                                 self._command_name))
 
         # reconstruct the cmd2 command from the python call
         cmd_str = ['']
@@ -248,23 +251,20 @@ class PyscriptBridge(object):
 
     def __getattr__(self, item: str):
         """Check if the attribute is a command. If so, return a callable."""
-        commands = self._cmd2_app.get_all_commands()
-        if item in commands:
-            func = getattr(self._cmd2_app, 'do_' + item)
-
-            try:
-                # See if the command uses argparse
-                parser = getattr(func, 'argparser')
-            except AttributeError:
-                # Command doesn't, we will accept parameters in the form of a command string
+        func = self._cmd2_app.cmd_func(item)
+
+        if func:
+            if hasattr(func, 'argparser'):
+                # Command uses argparse, return an object that can traverse the argparse subcommands and arguments
+                return ArgparseFunctor(self.cmd_echo, self._cmd2_app, item, getattr(func, 'argparser'))
+            else:
+                # Command doesn't use argparse, we will accept parameters in the form of a command string
                 def wrap_func(args=''):
                     return _exec_cmd(self._cmd2_app, functools.partial(func, args), self.cmd_echo)
-                return wrap_func
-            else:
-                # Command does use argparse, return an object that can traverse the argparse subcommands and arguments
-                return ArgparseFunctor(self.cmd_echo, self._cmd2_app, item, parser)
 
-        return super().__getattr__(item)
+                return wrap_func
+        else:
+            return super().__getattr__(item)
 
     def __dir__(self):
         """Return a custom set of attribute names to match the available commands"""
diff --git a/cmd2/utils.py b/cmd2/utils.py
index bdb488cc..ddd43507 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -6,7 +6,7 @@ import collections
 import os
 import re
 import unicodedata
-from typing import Any, List, Optional, Union
+from typing import Any, Iterable, List, Optional, Union
 
 from . import constants
 
@@ -194,7 +194,7 @@ def norm_fold(astr: str) -> str:
     return unicodedata.normalize('NFC', astr).casefold()
 
 
-def alphabetical_sort(list_to_sort: List[str]) -> List[str]:
+def alphabetical_sort(list_to_sort: Iterable[str]) -> List[str]:
     """Sorts a list of strings alphabetically.
 
     For example: ['a1', 'A11', 'A2', 'a22', 'a3']
@@ -229,10 +229,10 @@ def natural_keys(input_str: str) -> List[Union[int, str]]:
     :param input_str: string to convert
     :return: list of strings and integers
     """
-    return [try_int_or_force_to_lower_case(substr) for substr in re.split('(\d+)', input_str)]
+    return [try_int_or_force_to_lower_case(substr) for substr in re.split(r'(\d+)', input_str)]
 
 
-def natural_sort(list_to_sort: List[str]) -> List[str]:
+def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
     """
     Sorts a list of strings case insensitively as well as numerically.
 
@@ -304,3 +304,15 @@ class StdSim(object):
             return self.__dict__[item]
         else:
             return getattr(self.inner_stream, item)
+
+
+def unquote_redirection_tokens(args: List[str]) -> None:
+    """
+    Unquote redirection tokens in a list of command-line arguments
+    This is used when redirection tokens have to be passed to another command
+    :param args: the command line args
+    """
+    for i, arg in enumerate(args):
+        unquoted_arg = strip_quotes(arg)
+        if unquoted_arg in constants.REDIRECTION_TOKENS:
+            args[i] = unquoted_arg
diff --git a/docs/settingchanges.rst b/docs/settingchanges.rst
index e08b6026..25a671ab 100644
--- a/docs/settingchanges.rst
+++ b/docs/settingchanges.rst
@@ -50,17 +50,54 @@ To define more shortcuts, update the dict ``App.shortcuts`` with the
 Aliases
 =======
 
-In addition to shortcuts, ``cmd2`` provides a full alias feature via the ``alias`` command which is similar to the
-``alias`` command in Bash.
+In addition to shortcuts, ``cmd2`` provides a full alias feature via the ``alias`` command. Aliases work in a similar
+fashion to aliases in the Bash shell.
 
-The syntax to create an alias is ``alias <name> <value>``. ``value`` can contain spaces and does not need
-to be quoted. Ex: ``alias ls !ls -lF``
+The syntax to create an alias is: ``alias create name command [args]``.
 
-If ``alias`` is run without arguments, then a list of all aliases will be printed to stdout and are in the proper
-``alias`` command syntax, meaning they can easily be reused.
+  Ex: ``alias create ls !ls -lF``
 
-The ``unalias`` is used to clear aliases. Using the ``-a`` flag will clear all aliases. Otherwise provide a list of
-aliases to clear. Ex: ``unalias ls cd pwd`` will clear the aliases called ls, cd, and pwd.
+For more details run: ``help alias create``
+
+Use ``alias list`` to see all or some of your aliases. The output of this command displays your aliases using the same command that
+was used to create them. Therefore you can place this output in a ``cmd2`` startup script to recreate your aliases each time
+you start the application
+
+  Ex: ``alias list``
+
+For more details run: ``help alias list``
+
+Use ``alias delete`` to remove aliases
+
+For more details run: ``help alias delete``
+
+Macros
+======
+
+``cmd2`` provides a feature that is similar to aliases called macros. The major difference between macros and aliases
+is that macros are intended to take arguments when called. These can be useful if you need to run a complex command
+frequently with different arguments that appear in various parts of the command.
+
+Arguments are expressed when creating a macro using {#} notation where {1} means the first argument.
+
+The following creates a macro called my_macro that expects two arguments:
+
+  macro create my_macro make_dinner -meat {1} -veggie {2}
+
+When the macro is called, the provided arguments are resolved and the assembled
+command is run. For example:
+
+  my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli
+
+For more details run: ``help macro create``
+
+The macro command has ``list`` and ``delete`` subcommands that function identically to the alias subcommands of the
+same name. Like aliases, macros can be created via a ``cmd2`` startup script to preserve them across application
+sessions.
+
+For more details on listing macros run: ``help macro list``
+
+For more details on deleting macros run: ``help macro delete``
 
 
 Default to shell
diff --git a/examples/.cmd2rc b/examples/.cmd2rc
index 4ffedab9..cedcbe20 100644
--- a/examples/.cmd2rc
+++ b/examples/.cmd2rc
@@ -1,2 +1,2 @@
-alias ls !ls -hal
-alias pwd !pwd
+alias create ls !ls -hal
+alias create pwd !pwd
diff --git a/examples/arg_print.py b/examples/arg_print.py
index 4f0ca709..f2168126 100755
--- a/examples/arg_print.py
+++ b/examples/arg_print.py
@@ -2,13 +2,12 @@
 # coding=utf-8
 """A simple example demonstrating the following:
     1) How arguments and options get parsed and passed to commands
-    2) How to change what syntax get parsed as a comment and stripped from
-       the arguments
+    2) How to change what syntax gets parsed as a comment and stripped from the arguments
 
 This is intended to serve as a live demonstration so that developers can
 experiment with and understand how command and argument parsing work.
 
-It also serves as an example of how to create command aliases (shortcuts).
+It also serves as an example of how to create shortcuts.
 """
 import argparse
 
@@ -18,7 +17,7 @@ class ArgumentAndOptionPrinter(cmd2.Cmd):
     """ Example cmd2 application where we create commands that just print the arguments they are called with."""
 
     def __init__(self):
-        # Create command aliases which are shorter
+        # Create command shortcuts which are typically 1 character abbreviations which can be used in place of a command
         self.shortcuts.update({'$': 'aprint', '%': 'oprint'})
 
         # Make sure to call this super class __init__ *after* setting and/or updating shortcuts
diff --git a/examples/hooks.py b/examples/hooks.py
old mode 100644
new mode 100755
index 3d11457a..b6f6263e
--- a/examples/hooks.py
+++ b/examples/hooks.py
@@ -84,12 +84,12 @@ class CmdLineApp(cmd2.Cmd):
 
     def abbrev_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData:
         """Accept unique abbreviated commands"""
-        target = 'do_' + data.statement.command
-        if target not in dir(self):
+        func = self.cmd_func(data.statement.command)
+        if func is None:
             # check if the entered command might be an abbreviation
-            funcs = [func for func in self.keywords if func.startswith(data.statement.command)]
-            if len(funcs) == 1:
-                raw = data.statement.raw.replace(data.statement.command, funcs[0], 1)
+            possible_cmds = [cmd for cmd in self.keywords if cmd.startswith(data.statement.command)]
+            if len(possible_cmds) == 1:
+                raw = data.statement.raw.replace(data.statement.command, possible_cmds[0], 1)
                 data.statement = self.statement_parser.parse(raw)
         return data
 
diff --git a/main.py b/main.py
index 7aaec3a3..56383f66 100755
--- a/main.py
+++ b/main.py
@@ -7,6 +7,6 @@ if __name__ == '__main__':
 
     # Set "use_ipython" to True to include the ipy command if IPython is installed, which supports advanced interactive
     # debugging of your application via introspection on self.
-    app = cmd2.Cmd(use_ipython=True)
+    app = cmd2.Cmd(use_ipython=True, persistent_history_file='cmd2_history.txt')
     app.locals_in_py = True
     app.cmdloop()
diff --git a/setup.py b/setup.py
index 2947c2ea..861813c7 100755
--- a/setup.py
+++ b/setup.py
@@ -16,7 +16,7 @@ https://cmd2.readthedocs.io/
 
 Main features:
 
-    - Searchable command history (`history` command and `<Ctrl>+r`)
+    - Searchable command history (`history` command and `<Ctrl>+r`) - optionally persistent
     - Text file scripting of your application with `load` (`@`) and `_relative_load` (`@@`)
     - Python scripting of your application with ``pyscript``
     - Run shell commands with ``!``
@@ -24,8 +24,12 @@ Main features:
     - Redirect command output to file with `>`, `>>`
     - Bare `>`, `>>` with no filename send output to paste buffer (clipboard)
     - `py` enters interactive Python console (opt-in `ipy` for IPython console)
+    - Option to display long output using a pager with ``cmd2.Cmd.ppaged()``
     - Multi-line commands
     - Special-character command shortcuts (beyond cmd's `?` and `!`)
+    - Command aliasing similar to bash `alias` command
+    - Macros, which are similar to aliases, but can take arguments when called
+    - Ability to load commands at startup from an initialization script
     - Settable environment parameters
     - Parsing commands with arguments using `argparse`, including support for sub-commands
     - Unicode character support
@@ -34,6 +38,7 @@ Main features:
     - Trivial to provide built-in help for all commands
     - Built-in regression testing framework for your applications (transcript-based testing)
     - Transcripts for use with built-in regression can be automatically generated from `history -t`
+    - Alerts that seamlessly print while user enters text at prompt
 
 Usable without modification anywhere cmd is used; simply import cmd2.Cmd in place of cmd.Cmd.
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 39aa3473..2c7a2600 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,29 +29,29 @@ except ImportError:
 # Help text for base cmd2.Cmd application
 BASE_HELP = """Documented commands (type help <topic>):
 ========================================
-alias  help     load  pyscript  set    shortcuts
-edit   history  py    quit      shell  unalias
+alias  help     load   py        quit  shell    
+edit   history  macro  pyscript  set   shortcuts
 """
 
 BASE_HELP_VERBOSE = """
 Documented commands (type help <topic>):
 ================================================================================
-alias               Define or display aliases
+alias               Manage aliases
 edit                Edit a file in a text editor
 help                List available commands with "help" or detailed help with "help cmd"
 history             View, run, edit, save, or clear previously entered commands
 load                Runs commands in script file that is encoded as either ASCII or UTF-8 text
+macro               Manage macros
 py                  Invoke python command, shell, or script
 pyscript            Runs a python script file inside the console
 quit                Exits this application
 set                 Sets a settable parameter or shows current settings of parameters
 shell               Execute a command as if at the OS prompt
 shortcuts           Lists shortcuts available
-unalias             Unsets aliases
 """
 
 # Help text for the history command
-HELP_HISTORY = """Usage: history [arg] [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c]
+HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg]
 
 View, run, edit, save, or clear previously entered commands
 
@@ -160,11 +160,8 @@ def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Opti
     def get_endidx():
         return endidx
 
-    first_match = None
+    # Run the readline tab-completion function with readline mocks in place
     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
-                first_match = app.complete(text, 0)
-
-    return first_match
+                return app.complete(text, 0)
diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py
index 8035587a..c6c1d1f6 100644
--- a/tests/test_autocompletion.py
+++ b/tests/test_autocompletion.py
@@ -34,9 +34,9 @@ optional arguments:
                         	single value - maximum duration
                         	[a, b] - duration range'''
 
-MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add title {G, PG, PG-13, R, NC-17} [actor [...]]
-                        -d DIRECTOR{1..2}
+MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add -d DIRECTOR{1..2}
                         [-h]
+                        title {G, PG, PG-13, R, NC-17} [actor [...]]
 
 positional arguments:
   title                 Movie Title
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index dece1ab4..daca3087 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -78,7 +78,7 @@ def test_base_invalid_option(base_app, capsys):
     out = normalize(out)
     err = normalize(err)
     assert 'Error: unrecognized arguments: -z' in err[0]
-    assert out[0] == 'Usage: set [param] [value] [-h] [-a] [-l]'
+    assert out[0] == 'Usage: set [-h] [-a] [-l] [param] [value]'
 
 def test_base_shortcuts(base_app):
     out = run_cmd(base_app, 'shortcuts')
@@ -1159,8 +1159,8 @@ def test_custom_help_menu(help_app):
     expected = normalize("""
 Documented commands (type help <topic>):
 ========================================
-alias  help     load  pyscript  set    shortcuts  unalias
-edit   history  py    quit      shell  squat
+alias  help     load   py        quit  shell      squat
+edit   history  macro  pyscript  set   shortcuts
 
 Undocumented commands:
 ======================
@@ -1226,7 +1226,7 @@ diddly
 
 Other
 =====
-alias  help  history  load  py  pyscript  quit  set  shell  shortcuts  unalias
+alias  help  history  load  macro  py  pyscript  quit  set  shell  shortcuts
 
 Undocumented commands:
 ======================
@@ -1249,17 +1249,17 @@ diddly              This command does diddly
 
 Other
 ================================================================================
-alias               Define or display aliases
+alias               Manage aliases
 help                List available commands with "help" or detailed help with "help cmd"
 history             View, run, edit, save, or clear previously entered commands
 load                Runs commands in script file that is encoded as either ASCII or UTF-8 text
+macro               Manage macros
 py                  Invoke python command, shell, or script
 pyscript            Runs a python script file inside the console
 quit                Exits this application
 set                 Sets a settable parameter or shows current settings of parameters
 shell               Execute a command as if at the OS prompt
 shortcuts           Lists shortcuts available
-unalias             Unsets aliases
 
 Undocumented commands:
 ======================
@@ -1765,9 +1765,39 @@ def test_poutput_color_never(base_app):
     assert out == expected
 
 
-def test_alias(base_app, capsys):
+# These are invalid names for aliases and macros
+invalid_command_name = [
+    '""',  # Blank name
+    '!no_shortcut',
+    '">"',
+    '"no>pe"',
+    '"no spaces"',
+    '"nopipe|"',
+    '"noterm;"',
+    'noembedded"quotes',
+]
+
+def test_get_alias_names(base_app):
+    assert len(base_app.aliases) == 0
+    run_cmd(base_app, 'alias create fake pyscript')
+    run_cmd(base_app, 'alias create ls !ls -hal')
+    assert len(base_app.aliases) == 2
+    assert sorted(base_app.get_alias_names()) == ['fake', 'ls']
+
+def test_get_macro_names(base_app):
+    assert len(base_app.macros) == 0
+    run_cmd(base_app, 'macro create foo !echo foo')
+    run_cmd(base_app, 'macro create bar !echo bar')
+    assert len(base_app.macros) == 2
+    assert sorted(base_app.get_macro_names()) == ['bar', 'foo']
+
+def test_alias_no_subcommand(base_app, capsys):
+    out = run_cmd(base_app, 'alias')
+    assert "Usage: alias [-h]" in out[0]
+
+def test_alias_create(base_app, capsys):
     # Create the alias
-    out = run_cmd(base_app, 'alias fake pyscript')
+    out = run_cmd(base_app, 'alias create fake pyscript')
     assert out == normalize("Alias 'fake' created")
 
     # Use the alias
@@ -1776,82 +1806,85 @@ def test_alias(base_app, capsys):
     assert "pyscript command requires at least 1 argument" in err
 
     # See a list of aliases
-    out = run_cmd(base_app, 'alias')
-    assert out == normalize('alias fake pyscript')
+    out = run_cmd(base_app, 'alias list')
+    assert out == normalize('alias create fake pyscript')
 
-    # Lookup the new alias
-    out = run_cmd(base_app, 'alias fake')
-    assert out == normalize('alias fake pyscript')
+    # Look up the new alias
+    out = run_cmd(base_app, 'alias list fake')
+    assert out == normalize('alias create fake pyscript')
 
-def test_alias_with_quotes(base_app, capsys):
+def test_alias_quoted_name(base_app, capsys):
+    """Demonstrate that names can be quoted in alias commands because they will be stripped"""
     # Create the alias
-    out = run_cmd(base_app, 'alias fake help ">" "out file.txt"')
+    out = run_cmd(base_app, 'alias create "fake" pyscript')
+
+    # The quotes on names are stripped
     assert out == normalize("Alias 'fake' created")
 
-    # Lookup the new alias (Only the redirector should be unquoted)
-    out = run_cmd(base_app, 'alias fake')
-    assert out == normalize('alias fake help > "out file.txt"')
+    # Look up the new alias and quote the name
+    out = run_cmd(base_app, 'alias list "fake"')
+    assert out == normalize('alias create fake pyscript')
 
-def test_alias_lookup_invalid_alias(base_app, capsys):
-    # Lookup invalid alias
-    out = run_cmd(base_app, 'alias invalid')
-    out, err = capsys.readouterr()
-    assert "not found" in err
+    # Delete the alias using quotes
+    out = run_cmd(base_app, 'alias delete "fake"')
+    assert out == normalize("Alias 'fake' deleted")
 
-def test_unalias(base_app):
-    # Create an alias
-    run_cmd(base_app, 'alias fake pyscript')
+def test_alias_create_with_quoted_value(base_app, capsys):
+    """Demonstrate that quotes in alias value will be preserved (except for redirectors)"""
 
-    # Remove the alias
-    out = run_cmd(base_app, 'unalias fake')
-    assert out == normalize("Alias 'fake' cleared")
+    # Create the alias
+    out = run_cmd(base_app, 'alias create fake help ">" "out file.txt"')
+    assert out == normalize("Alias 'fake' created")
 
-def test_unalias_all(base_app):
-    out = run_cmd(base_app, 'unalias -a')
-    assert out == normalize("All aliases cleared")
+    # Look up the new alias (Only the redirector should be unquoted)
+    out = run_cmd(base_app, 'alias list fake')
+    assert out == normalize('alias create fake help > "out file.txt"')
 
-def test_unalias_non_existing(base_app, capsys):
-    run_cmd(base_app, 'unalias fake')
+@pytest.mark.parametrize('alias_name', invalid_command_name)
+def test_alias_create_invalid_name(base_app, alias_name, capsys):
+    run_cmd(base_app, 'alias create {} help'.format(alias_name))
     out, err = capsys.readouterr()
-    assert "does not exist" in err
+    assert "Invalid alias name" in err
 
-@pytest.mark.parametrize('alias_name', [
-    '">"',
-    '"no>pe"',
-    '"no spaces"',
-    '"nopipe|"',
-    '"noterm;"',
-    'noembedded"quotes',
-])
-def test_create_invalid_alias(base_app, alias_name, capsys):
-    run_cmd(base_app, 'alias {} help'.format(alias_name))
+def test_alias_create_with_macro_name(base_app, capsys):
+    macro = "my_macro"
+    run_cmd(base_app, 'macro create {} help'.format(macro))
+    run_cmd(base_app, 'alias create {} help'.format(macro))
     out, err = capsys.readouterr()
-    assert "can not contain" in err
+    assert "Alias cannot have the same name as a macro" in err
 
-def test_complete_unalias(base_app):
-    text = 'f'
-    line = text
-    endidx = len(line)
-    begidx = endidx - len(text)
+def test_alias_list_invalid_alias(base_app, capsys):
+    # Look up invalid alias
+    out = run_cmd(base_app, 'alias list invalid')
+    out, err = capsys.readouterr()
+    assert "Alias 'invalid' not found" in err
+
+def test_alias_delete(base_app):
+    # Create an alias
+    run_cmd(base_app, 'alias create fake pyscript')
 
-    # Validate there are no completions when there are no aliases
-    assert base_app.complete_unalias(text, line, begidx, endidx) == []
+    # Delete the alias
+    out = run_cmd(base_app, 'alias delete fake')
+    assert out == normalize("Alias 'fake' deleted")
 
-    # Create a few aliases - two the start with 'f' and one that doesn't
-    run_cmd(base_app, 'alias fall quit')
-    run_cmd(base_app, 'alias fake pyscript')
-    run_cmd(base_app, 'alias carapace shell')
+def test_alias_delete_all(base_app):
+    out = run_cmd(base_app, 'alias delete --all')
+    assert out == normalize("All aliases deleted")
+
+def test_alias_delete_non_existing(base_app, capsys):
+    run_cmd(base_app, 'alias delete fake')
+    out, err = capsys.readouterr()
+    assert "Alias 'fake' does not exist" in err
 
-    # Validate that there are now completions
-    expected = ['fake', 'fall']
-    result = base_app.complete_unalias(text, line, begidx, endidx)
-    assert sorted(expected) == sorted(result)
+def test_alias_delete_no_name(base_app, capsys):
+    out = run_cmd(base_app, 'alias delete')
+    assert "Usage: alias delete" in out[0]
 
 def test_multiple_aliases(base_app):
     alias1 = 'h1'
     alias2 = 'h2'
-    run_cmd(base_app, 'alias {} help'.format(alias1))
-    run_cmd(base_app, 'alias {} help -v'.format(alias2))
+    run_cmd(base_app, 'alias create {} help'.format(alias1))
+    run_cmd(base_app, 'alias create {} help -v'.format(alias2))
     out = run_cmd(base_app, alias1)
     expected = normalize(BASE_HELP)
     assert out == expected
@@ -1860,6 +1893,180 @@ def test_multiple_aliases(base_app):
     expected = normalize(BASE_HELP_VERBOSE)
     assert out == expected
 
+def test_macro_no_subcommand(base_app, capsys):
+    out = run_cmd(base_app, 'macro')
+    assert "Usage: macro [-h]" in out[0]
+
+def test_macro_create(base_app, capsys):
+    # Create the macro
+    out = run_cmd(base_app, 'macro create fake pyscript')
+    assert out == normalize("Macro 'fake' created")
+
+    # Use the macro
+    run_cmd(base_app, 'fake')
+    out, err = capsys.readouterr()
+    assert "pyscript command requires at least 1 argument" in err
+
+    # See a list of macros
+    out = run_cmd(base_app, 'macro list')
+    assert out == normalize('macro create fake pyscript')
+
+    # Look up the new macro
+    out = run_cmd(base_app, 'macro list fake')
+    assert out == normalize('macro create fake pyscript')
+
+def test_macro_create_quoted_name(base_app, capsys):
+    """Demonstrate that names can be quoted in macro commands because they will be stripped"""
+    # Create the macro
+    out = run_cmd(base_app, 'macro create "fake" pyscript')
+
+    # The quotes on names are stripped
+    assert out == normalize("Macro 'fake' created")
+
+    # Look up the new macro and quote the name
+    out = run_cmd(base_app, 'macro list "fake"')
+    assert out == normalize('macro create fake pyscript')
+
+    # Delete the macro using quotes
+    out = run_cmd(base_app, 'macro delete "fake"')
+    assert out == normalize("Macro 'fake' deleted")
+
+def test_macro_create_with_quoted_value(base_app, capsys):
+    """Demonstrate that quotes in macro value will be preserved (except for redirectors)"""
+    # Create the macro
+    out = run_cmd(base_app, 'macro create fake help ">" "out file.txt"')
+    assert out == normalize("Macro 'fake' created")
+
+    # Look up the new macro (Only the redirector should be unquoted)
+    out = run_cmd(base_app, 'macro list fake')
+    assert out == normalize('macro create fake help > "out file.txt"')
+
+@pytest.mark.parametrize('macro_name', invalid_command_name)
+def test_macro_create_invalid_name(base_app, macro_name, capsys):
+    run_cmd(base_app, 'macro create {} help'.format(macro_name))
+    out, err = capsys.readouterr()
+    assert "Invalid macro name" in err
+
+def test_macro_create_with_alias_name(base_app, capsys):
+    macro = "my_macro"
+    run_cmd(base_app, 'alias create {} help'.format(macro))
+    run_cmd(base_app, 'macro create {} help'.format(macro))
+    out, err = capsys.readouterr()
+    assert "Macro cannot have the same name as an alias" in err
+
+def test_macro_create_with_command_name(base_app, capsys):
+    macro = "my_macro"
+    run_cmd(base_app, 'macro create help stuff')
+    out, err = capsys.readouterr()
+    assert "Macro cannot have the same name as a command" in err
+
+def test_macro_create_with_args(base_app, capsys):
+    # Create the macro
+    out = run_cmd(base_app, 'macro create fake {1} {2}')
+    assert out == normalize("Macro 'fake' created")
+
+    # Run the macro
+    out = run_cmd(base_app, 'fake help -v')
+    expected = normalize(BASE_HELP_VERBOSE)
+    assert out == expected
+
+def test_macro_create_with_escaped_args(base_app, capsys):
+    # Create the macro
+    out = run_cmd(base_app, 'macro create fake help {{1}}')
+    assert out == normalize("Macro 'fake' created")
+
+    # Run the macro
+    out = run_cmd(base_app, 'fake')
+    assert 'No help on {1}' in out[0]
+
+def test_macro_create_with_missing_arg_nums(base_app, capsys):
+    # Create the macro
+    run_cmd(base_app, 'macro create fake help {1} {3}')
+    out, err = capsys.readouterr()
+    assert "Not all numbers between 1 and 3" in err
+
+def test_macro_create_with_invalid_arg_num(base_app, capsys):
+    # Create the macro
+    run_cmd(base_app, 'macro create fake help {1} {-1} {0}')
+    out, err = capsys.readouterr()
+    assert "Argument numbers must be greater than 0" in err
+
+def test_macro_create_with_wrong_arg_count(base_app, capsys):
+    # Create the macro
+    out = run_cmd(base_app, 'macro create fake help {1} {2}')
+    assert out == normalize("Macro 'fake' created")
+
+    # Run the macro
+    run_cmd(base_app, 'fake arg1')
+    out, err = capsys.readouterr()
+    assert "expects 2 argument(s)" in err
+
+def test_macro_create_with_unicode_numbered_arg(base_app, capsys):
+    # Create the macro expecting 1 argument
+    out = run_cmd(base_app, 'macro create fake help {\N{ARABIC-INDIC DIGIT ONE}}')
+    assert out == normalize("Macro 'fake' created")
+
+    # Run the macro
+    out = run_cmd(base_app, 'fake')
+    out, err = capsys.readouterr()
+    assert "expects 1 argument(s)" in err
+
+def test_macro_create_with_missing_unicode_arg_nums(base_app, capsys):
+    run_cmd(base_app, 'macro create fake help {1} {\N{ARABIC-INDIC DIGIT THREE}}')
+    out, err = capsys.readouterr()
+    assert "Not all numbers between 1 and 3" in err
+
+def test_macro_list_invalid_macro(base_app, capsys):
+    # Look up invalid macro
+    run_cmd(base_app, 'macro list invalid')
+    out, err = capsys.readouterr()
+    assert "Macro 'invalid' not found" in err
+
+def test_macro_delete(base_app):
+    # Create an macro
+    run_cmd(base_app, 'macro create fake pyscript')
+
+    # Delete the macro
+    out = run_cmd(base_app, 'macro delete fake')
+    assert out == normalize("Macro 'fake' deleted")
+
+def test_macro_delete_all(base_app):
+    out = run_cmd(base_app, 'macro delete --all')
+    assert out == normalize("All macros deleted")
+
+def test_macro_delete_non_existing(base_app, capsys):
+    run_cmd(base_app, 'macro delete fake')
+    out, err = capsys.readouterr()
+    assert "Macro 'fake' does not exist" in err
+
+def test_macro_delete_no_name(base_app, capsys):
+    out = run_cmd(base_app, 'macro delete')
+    assert "Usage: macro delete" in out[0]
+
+def test_multiple_macros(base_app):
+    macro1 = 'h1'
+    macro2 = 'h2'
+    run_cmd(base_app, 'macro create {} help'.format(macro1))
+    run_cmd(base_app, 'macro create {} help -v'.format(macro2))
+    out = run_cmd(base_app, macro1)
+    expected = normalize(BASE_HELP)
+    assert out == expected
+
+    out = run_cmd(base_app, macro2)
+    expected = normalize(BASE_HELP_VERBOSE)
+    assert out == expected
+
+def test_nonexistent_macro(base_app, capsys):
+    from cmd2.parsing import StatementParser
+    exception = None
+
+    try:
+        base_app._run_macro(StatementParser().parse('fake'))
+    except KeyError as e:
+        exception = e
+
+    assert exception is not None
+
 
 def test_ppaged(base_app):
     msg = 'testing...'
@@ -1985,8 +2192,8 @@ def test_bad_history_file_path(capsys, request):
 def test_get_all_commands(base_app):
     # Verify that the base app has the expected commands
     commands = base_app.get_all_commands()
-    expected_commands = ['_relative_load', 'alias', 'edit', 'eof', 'eos', 'help', 'history', 'load', 'py', 'pyscript',
-                         'quit', 'set', 'shell', 'shortcuts', 'unalias']
+    expected_commands = ['_relative_load', 'alias', 'edit', 'eof', 'eos', 'help', 'history', 'load', 'macro',
+                         'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts']
     assert commands == expected_commands
 
 def test_get_help_topics(base_app):
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 40299954..f02989bf 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -15,7 +15,7 @@ import sys
 import pytest
 import cmd2
 from cmd2 import utils
-from .conftest import complete_tester
+from .conftest import base_app, complete_tester, normalize, run_cmd
 from examples.subcommands import SubcommandsExample
 
 # List of strings used with completion functions
@@ -101,8 +101,7 @@ def test_complete_empty_arg(cmd2_app):
     expected = sorted(cmd2_app.complete_help(text, line, begidx, endidx))
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
 
-    assert first_match is not None and \
-        cmd2_app.completion_matches == expected
+    assert first_match is not None and cmd2_app.completion_matches == expected
 
 def test_complete_bogus_command(cmd2_app):
     text = ''
@@ -113,6 +112,24 @@ def test_complete_bogus_command(cmd2_app):
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is None
 
+def test_complete_macro(base_app, request):
+    # Create the macro
+    out = run_cmd(base_app, 'macro create fake pyscript {1}')
+    assert out == normalize("Macro 'fake' created")
+
+    # Macros do path completion
+    test_dir = os.path.dirname(request.module.__file__)
+
+    text = os.path.join(test_dir, 's')
+    line = 'fake {}'.format(text)
+
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep]
+    first_match = complete_tester(text, line, begidx, endidx, base_app)
+    assert first_match is not None and base_app.completion_matches
+
 
 def test_cmd2_command_completion_multiple(cmd2_app):
     text = 'h'
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index 9cf9429a..1fa460a5 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -754,3 +754,104 @@ def test_statement_is_immutable():
         statement.args = 'bar'
     with pytest.raises(attr.exceptions.FrozenInstanceError):
         statement.raw = 'baz'
+
+
+def test_macro_normal_arg_pattern():
+    # This pattern matches digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side
+    from cmd2.parsing import MacroArg
+    pattern = MacroArg.macro_normal_arg_pattern
+
+    # Valid strings
+    matches = pattern.findall('{5}')
+    assert matches == ['{5}']
+
+    matches = pattern.findall('{233}')
+    assert matches == ['{233}']
+
+    matches = pattern.findall('{{{{{4}')
+    assert matches == ['{4}']
+
+    matches = pattern.findall('{2}}}}}')
+    assert matches == ['{2}']
+
+    matches = pattern.findall('{3}{4}{5}')
+    assert matches == ['{3}', '{4}', '{5}']
+
+    matches = pattern.findall('{3} {4} {5}')
+    assert matches == ['{3}', '{4}', '{5}']
+
+    matches = pattern.findall('{3} {{{4} {5}}}}')
+    assert matches == ['{3}', '{4}', '{5}']
+
+    matches = pattern.findall('{3} text {4} stuff {5}}}}')
+    assert matches == ['{3}', '{4}', '{5}']
+
+    # Unicode digit
+    matches = pattern.findall('{\N{ARABIC-INDIC DIGIT ONE}}')
+    assert matches == ['{\N{ARABIC-INDIC DIGIT ONE}}']
+
+    # Invalid strings
+    matches = pattern.findall('5')
+    assert not matches
+
+    matches = pattern.findall('{5')
+    assert not matches
+
+    matches = pattern.findall('5}')
+    assert not matches
+
+    matches = pattern.findall('{{5}}')
+    assert not matches
+
+    matches = pattern.findall('{5text}')
+    assert not matches
+
+def test_macro_escaped_arg_pattern():
+    # This pattern matches digits surrounded by 2 or more braces on both sides
+    from cmd2.parsing import MacroArg
+    pattern = MacroArg.macro_escaped_arg_pattern
+
+    # Valid strings
+    matches = pattern.findall('{{5}}')
+    assert matches == ['{{5}}']
+
+    matches = pattern.findall('{{233}}')
+    assert matches == ['{{233}}']
+
+    matches = pattern.findall('{{{{{4}}')
+    assert matches == ['{{4}}']
+
+    matches = pattern.findall('{{2}}}}}')
+    assert matches == ['{{2}}']
+
+    matches = pattern.findall('{{3}}{{4}}{{5}}')
+    assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+    matches = pattern.findall('{{3}} {{4}} {{5}}')
+    assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+    matches = pattern.findall('{{3}} {{{4}} {{5}}}}')
+    assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+    matches = pattern.findall('{{3}} text {{4}} stuff {{5}}}}')
+    assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+    # Unicode digit
+    matches = pattern.findall('{{\N{ARABIC-INDIC DIGIT ONE}}}')
+    assert matches == ['{{\N{ARABIC-INDIC DIGIT ONE}}}']
+
+    # Invalid strings
+    matches = pattern.findall('5')
+    assert not matches
+
+    matches = pattern.findall('{{5')
+    assert not matches
+
+    matches = pattern.findall('5}}')
+    assert not matches
+
+    matches = pattern.findall('{5}')
+    assert not matches
+
+    matches = pattern.findall('{{5text}}')
+    assert not matches
diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py
index d5e5a4fb..84abc965 100644
--- a/tests/test_pyscript.py
+++ b/tests/test_pyscript.py
@@ -210,7 +210,7 @@ def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out):
 
 
 @pytest.mark.parametrize('expected, pyscript_file', [
-    ("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts', 'unalias']",
+    ("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'macro', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts']",
      'pyscript_dir1.py'),
     ("['movies', 'shows']", 'pyscript_dir2.py')
 ])
diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt
index 5f22d756..56bbdc0c 100644
--- a/tests/transcripts/from_cmdloop.txt
+++ b/tests/transcripts/from_cmdloop.txt
@@ -5,8 +5,8 @@
 
 Documented commands (type help <topic>):
 ========================================
-alias  help     load    orate  pyscript  say  shell      speak/ */
-edit   history  mumble  py     quit      set  shortcuts  unalias/ */
+alias  help     load   mumble  py        quit  set    shortcuts/ */
+edit   history  macro  orate   pyscript  say   shell  speak/ */
 
 (Cmd) help say
 usage: speak [-h] [-p] [-s] [-r REPEAT]/ */