NAME

Term::CLI::Tutorial - tips, tricks, and examples for Term::CLI

VERSION

version 0.059000

SYNOPSIS

use Term::CLI;

DESCRIPTION

This manual shows how to use Term::CLI to build a working CLI application with command-line editing capabilities, command history, command completion, and more.

For an introduction in the object class structure, see Term::CLI::Intro(3p).

Note on Term::ReadLine

Although Term::CLI has been created to work with Term::ReadLine in general, it works best when Term::ReadLine::Gnu(3p) is present; failing that, Term::ReadLine::Perl(3p) will also suffice, although there are some subtle differences (especially in signal handling). If neither is present on your system, Term::ReadLine will load a "stub" interface (Term::ReadLine::Stub), which supports neither command line editing nor completion.

INTRODUCTION

If you have ever found yourself needing to write a command-line (shell-like) interface to your program, then Term::CLI may be for you.

Term::CLI provides a readline-based command line interface, including history, completion and input verification.

The most notable features are:

  • syntax checking, including option parsing

  • command, filename, and parameter completion

  • command and parameter abbreviation

  • command callbacks

Input syntax is specified by combining Term::CLI::Command and Term::CLI::Argument objects, together with Getopt::Long-like option specifications, and providing callback functions for command execution.

In the following sections, we will embark on the journey to building a simple shell with a few basic commands, but one that looks quite polished.

The tutorial directory in the module's source tree has source code for all examples (example_01_basic_repl.pl, example_02.pl, etc.), that progressively build the final application.

THE BSSH CONCEPT

The Basically Simple SHell (BS Shell), is a command-line interpreter with a few simple commands:

cp src-path ... dst-path

Copy src to dst.

echo [ arg ... ]

Print arguments to STDOUT and terminate with a newline.

exit [ code ]

Exit with code code (0 if not given).

ls [ file ... ]

See ls(1).

make {love|money} {now|later|never|forever}

A silly command for illustration purposes.

sleep seconds

Sleep for seconds seconds.

show {clock|load|terminal}

Show some system information.

set verbose bool

Set program verbosity.

set delimiter bool

Set word delimiter(s).

do {something|nothing} while {working|sleeping}

Do something during another activity.

interface iface {up|down}

Turn interface iface up or down.

That's it. Now, let's start building something.

THE REPL

The basic design of an interactive interface follows the well-established REPL (Read, Evaluate, Print, Loop) principle:

LOOP
    input = read_a_line
    output = evaluate_line( input )
    print_result( output )
END-LOOP

Term::CLI provides a framework to make this happen:

use 5.014_001;
use warnings;
use Term::CLI;

my $term = Term::CLI->new(
    name => 'bssh',             # A basically simple shell.
);

say "\n[Welcome to BSSH]";
while (defined (my $line = $term->readline)) {
    $term->execute_line($line);
}
say "\n-- exit";
exit 0;

This example is pretty much non-functional, since the Term::CLI object is not aware of any command syntax yet: everything you type will result in an error, even empty lines and comments (i.e. lines starting with # as the first non-blank character).

bash$ perl tutorial/example_01_basic_repl.pl

[Welcome to BSSH]

~>
ERROR: missing command

~> # This is a comment!
ERROR: unknown command '#'

~> exit
ERROR: unknown command 'exit'

~> ^D
-- exit

Ignoring input patterns

Let's first make sure that empty lines and comments are ignored. We could add a line to the while loop:

while (my $line = $term->readline) {
    next if /^\s*(?:#.*)?$/; # Skip comments and empty lines.
    $term->execute_line($line);
}

But it's actually nicer to let Term::CLI handle this for us:

my $term = Term::CLI->new(
    name => 'bssh',             # A basically simple shell.
    skip => qr/^\s*(?:#.*)?$/,  # Skip comments and empty lines.
);

Now we get:

bash$ perl tutorial/example_02_ignore_blank.pl

[Welcome to BSSH]
~>
~> # This is a comment!
~> exit
ERROR: unknown command 'exit'
~> ^D
-- exit

Setting the prompt

The default prompt for Term::CLI is ~>. To change this, we can call the prompt method, or just specify it as an argument to the constructor:

my $term = Term::CLI->new(
    name   => 'bssh',             # A basically simple shell.
    skip   => qr/^\s*(?:#.*)?$/,  # Skip comments and empty lines.
    prompt => 'bssh> ',           # A more descriptive prompt.
);

This gives us:

bash$ perl tutorial/example_03_setting_prompt.pl

[Welcome to BSSH]
bssh>
bssh> # This is a comment!
bssh> exit
ERROR: unknown command 'exit'
bssh> ^D
-- exit

ADDING COMMANDS

Adding a command to a Term::CLI object is a matter of creating an array of Term::CLI::Command instances and passing it to the Term::CLI's add_command method.

my $term = Term::CLI->new(
    name     => 'bssh',             # A basically simple shell.
    skip     => qr/^\s*(?:#.*)?$/,  # Skip comments and empty lines.
    prompt   => 'bssh> ',           # A more descriptive prompt.
);

$term->add_command(
    Term::CLI::Command->new( ... ),
    ...
);

It is also possible to build the commands list inside the constructor call:

my $term = Term::CLI->new(
    ...
    commands => [
        Term::CLI::Command->new( ... ),
        ...
    ]
);

However, the code quickly becomes unwieldy when a large number of commands and options are added.

You can also build a list first, and then call add_command:

my $term = Term::CLI->new(
    ...
);

my @commands;
push @commands, Term::CLI::Command->new(
    ...
);

...

$term->add_command(@commands);

This is the method we'll use for this tutorial, and it coincidentally comes in handy further down the line.

So, now that we have the basic mechanism out of the way, let's add our first command, the highly useful exit.

The exit command (optional argument)

From THE BSSH CONCEPT section above:

    exit [ code ]

This illustrates the use of a single, optional argument. Here's the code:

push @commands, Term::CLI::Command->new(
    name => 'exit',
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;
        execute_exit($cmd, @{$args{arguments}});
        return %args;
    },
    arguments => [
        Term::CLI::Argument::Number::Int->new(  # Integer
            name => 'excode',
            min => 0,             # non-negative
            inclusive => 1,       # "0" is allowed
            min_occur => 0,       # occurrence is optional
            max_occur => 1,       # no more than once
        ),
    ],
);

Let's unpack that, shall we?

The Term::CLI::Command constructor takes three attributes:

name => 'exit'

The name of the command. This is a mandatory attribute.

callback => \&execute_exit

The function to call when the command is executed.

arguments => [ ... ]

A list of arguments that the command takes.

The callback function

The callback function is called when the command is executed.

callback => sub {
    my ($cmd, %args) = @_;
    return %args if $args{status} < 0;
    execute_exit($cmd, @{$args{arguments}});
    return %args;
},

In this case, we also have to define execute_exit:

sub execute_exit {
    my ($cmd, $excode) = @_;
    $excode //= 0;
    say "-- exit: $excode";
    exit $excode;
}

The callback function (see callback in Term::CLI::Role::CommandSet) is called with a reference to the command object that owns the callback, along with a number of (key, value) pairs. It is expected to return a similar structure (while possibly modifying the status and/or error values).

Since the callback function is called even in the face of parse errors, it is important to check the status flag. A negative value indicates a parse error, so we don't do anything in that case (the Term::CLI default callback will print the error for us).

The command arguments are found under the arguments key, as an ArrayRef of scalars. The exit code is the only (optional) argument, so that is found as the first element of the list: $args{arguments}->[0]. If it is not given, we default to 0.

The arguments list

The arguments attribute is an ArrayRef made up of Term::CLI::Argument instances, or more precisely, object classes derived from that. At this moment, we have a number of pre-defined sub-classes: Term::CLI::Argument::Bool, Term::CLI::Argument::Enum, Term::CLI::Argument::Number::Float. Term::CLI::Argument::Number::Int, Term::CLI::Argument::Filename, Term::CLI::Argument::String. In our case, we need an optional, non-negative integer, so:

Term::CLI::Argument::Number::Int->new(  # Integer
    name => 'excode',
    min => 0,             # non-negative
    inclusive => 1,       # "0" is allowed
    min_occur => 0,       # occurrence is optional
    max_occur => 1,       # no more than once
),

The inclusive and max_occur can be left out in this case, as their defaults are 1 anyway.

Trying out the exit command

bash$ perl tutorial/example_04.pl

[Welcome to BSSH]
bssh> exit ok
ERROR: arg#1, 'ok': not a valid number for excode
bssh> exit 0 1
ERROR: arg#1, excode: too many arguments
bssh> exit 2
-- exit: 2

Note that command abbreviation also works, i.e. you can type:

e
ex
exi
exit

GETTING HELP

Before adding more commands to our application, it's perhaps a good moment to look at the built-in help features of Term::CLI.

By default, there is no help available in a Term::CLI application:

bssh> help
ERROR: unknown command 'help'

However, there is a special Term::CLI::Command::Help class (derived from Term::CLI::Command) that implements a help command, including command line completion:

push @commands, Term::CLI::Command::Help->new();

If you add this to the application, you'll get:

bash$ perl tutorial/example_05_add_help.pl

[Welcome to BSSH]
bssh> help
Commands:
    exit [excode]
    help [cmd ...]             show help
bssh> help exit
Usage:
    exit [excode]
bssh> help h
  Usage:
    help [--pod] [--all] [-pa] [cmd ...]

  Description:
    Show help for any given command sequence (or a command overview
    if no argument is given.

    The "--pod" ("-p") option will cause raw POD to be shown.

    The "--all" ("-a") option will list help text for all commands.

Note that we don't have to specify the full command to get help on: command abbreviation works here as well (help h). Also, if you'd type help h, then hit the TAB key, it would autocomplete to help help.

The --pod option is handy if you want to copy the help text into a manual page:

bssh> help --pod help

=head2 Usage:

B<help> [B<--pod>] [B<--all>] [B<-pa>] [I<cmd> ...]

=head2 Description:

Show help for any given command sequence (or a command
overview if no argument is given.

The C<--pod> (C<-p>) option will cause raw POD
to be shown.

The C<--all> (C<-a>) option will list help text for all
commands.

Fleshing out help text

As you may have already seen, the help text for the exit command is rather sparse (unlike that of the help command itself): it only shows a "usage" line.

The Term::CLI::Command::Help class is smart enough to construct a usage line from the given command (including its options, parameters and sub-commands), but it cannot magically describe what a command is all about. You'll have to specify that yourself, using the summary and description attributes in the exit command definition:

push @commands, Term::CLI::Command->new(
    name => 'exit',
    summary => 'exit B<bssh>',
    description => "Exit B<bssh> with code I<excode>,\n"
                ."or C<0> if no exit code is given.",
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;
        execute_exit($cmd, @{$args{arguments}});
        return %args;
    },
    arguments => [
        Term::CLI::Argument::Number::Int->new(  # Integer
            name => 'excode',
            min => 0,             # non-negative
            inclusive => 1,       # "0" is allowed
            min_occur => 0,       # occurrence is optional
            max_occur => 1,       # no more than once
        ),
    ],
);

The summary text is what is displayed in the command summary, the description text is shown in the full help for the command:

bash $perl tutorial/example_06_add_help_text.pl

[Welcome to BSSH]
bssh> help
  Commands:
    exit [excode]              exit bssh
    help [cmd ...]             show help
bssh> help exit
  Usage:
    exit [excode]

  Description:
    Exit bssh with code excode, or 0 if no exit code is given.

The help text is in POD format, translated for the screen using Pod::Text::Termcap(3p), and piped through an appropriate pager (see Term::CLI::Command::Help for more details).

ADDING MORE COMMANDS

The following examples will show various types and combination of arguments:

The echo command (optional arguments)

Next up, the echo command. From THE BSSH CONCEPT section above:

    echo [ arg ... ]

That is, the echo command takes zero or more arbitrary string arguments.

The implementation is straightforward:

push @commands, Term::CLI::Command->new(
    name => 'echo',
    summary => 'print arguments to F<stdout>',
    description => "The C<echo> command prints its arguments\n"
                .  "to F<stdout>, separated by spaces, and\n"
                .  "terminated by a newline.\n",
    arguments => [
        Term::CLI::Argument::String->new( name => 'arg', occur => 0 ),
    ],
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;
        say "@{$args{arguments}}";
        return %args;
    }
);

However, the echo and exit commands both start with the same prefix (e), so let's see what happens with the abbreviations:

bash$ perl tutorial/example_07_echo_command.pl

[Welcome to BSSH]
bssh> e hello, world
ERROR: ambiguous command 'e' (matches: echo, exit)
bssh> ec hello, world
hello, world
bssh> ex
-- exit: 0

The make command (enum arguments)

From THE BSSH CONCEPT section above:

    make {love|money} {now|later|never|forever}

Arguments with fixed set of values can be specified with Term::CLI::Argument::Enum objects:

push @commands, Term::CLI::Command->new(
    name => 'make',
    summary => 'make I<target> at time I<when>',
    description => "Make I<target> at time I<when>.\n"
                .  "Possible values for I<target> are:\n"
                .  "C<love>, C<money>.\n"
                .  "Possible values for I<when> are:\n"
                .  "C<now>, C<never>, C<later>, or C<forever>.",
    arguments => [
        Term::CLI::Argument::Enum->new( name => 'target',
            value_list => [qw( love money )],
        ),
        Term::CLI::Argument::Enum->new( name => 'when',
            value_list => [qw( now later never forever )],
        ),
    ],
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;
        my @args = @{$args{arguments}};
        say "making $args[0] $args[1]";
        return %args;
    }
);

The "enum" parameters support completion, as well as abbreviations. Thus, m m l will expand to make money later, and make l n will fail because n is ambiguous:

bash$ perl tutorial/example_08_make_command.pl

[Welcome to BSSH]
bssh> m m l
making money later
bssh> m l n
ERROR: arg#2, 'n': ambiguous value (matches: never, now) for 'when'

Command and parameter completion

m<TAB>          make
m l<TAB>        m love
m l l<TAB>      m l later
m l n<TAB>      m l n
m l n<TAB><TAB> (displays "never" and "now" as completions)

The ls command (file name arguments)

The ls command takes zero or more file name arguments. From THE BSSH CONCEPT section above:

    ls [ path ... ]

The code for this:

push @commands, Term::CLI::Command->new(
    name => 'ls',
    summary => 'list file(s)',
    description => "List file(s) given by the arguments.\n"
                .  "If no arguments are given, the command\n"
                .  "will list the current directory.",
    arguments => [
        Term::CLI::Argument::Filename->new( name => 'arg', occur => 0 ),
    ],
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;
        my @args = @{$args{arguments}};
        system('ls', @args);
        $args{status} = $?;
        return %args;
    }
);

Output should look like:

bash$ perl tutorial/example_09_ls_command.pl

[Welcome to BSSH]
bssh> ls
blib      lib           MANIFEST     t
Copying   Makefile      MYMETA.json  Term-CLI-0.01.tar.gz
cover_db  Makefile.old  MYMETA.yml   TODO
examples  Makefile.PL   pm_to_blib   tutorial
bssh> _

Options are passed directly to the ls(1) command. This is because we didn't specify any options in the command definition, so everything is assumed to be an argument, and the Term::CLI::Argument::Filename class is not particularly picky about the arguments it gets, juost so long as they are not empty:

bssh> ls -F lib/Term
CLI/  CLI.pm
bssh> _

File name completion

ls t<TAB><TAB>          (lists "t/" and "tutorial/" as completions)
ls tu<TAB>              ls tutorial
ls tutorial e<TAB>      ls tutorial examples

The cp command (variable number of arguments)

From THE BSSH CONCEPT section above:

    cp src-path ... dst-path

Ideally, we would like to specify this as:

Term::CLI::Command->new(
    name => 'cp',
    arguments => [
        Term::CLI::Argument::Filename->new(
            name => 'src-path',
            min_occur => 1,
            max_occur => 0 ),
        Term::CLI::Argument::Filename->new(
            name => 'dst-path',
            min_occur => 1,
            max_occur => 1 ),
    ],
    ...
)

Unfortunately, that will not work. Term::CLI::Command can work with a variable number of arguments, but only if that variable number is at the end of the list.

To see why this is the case, it is important to realise that Term::CLI parses an input line strictly from left to right, without any backtracking (which proper recursive descent parsers typically do). So, suppose you enter cp foo bar<TAB>. The completion code now has to decide what this bar is that needs to be completed. Since the first argument to cp can be one or more file names, this bar can be a src-path, but it can also be meant to be a dst-path. There is no way to tell for certain, so the code will be "greedy", in the sense that it will classify all arguments as src-path arguments.

There's no way around this, except by using options, but that's a separate topic.

For now, there's no other way than to specify a single Term::CLI::Argument::Filename, with a minimum occurrence of 2, and no maximum. The distinction between src-path and dst-path needs to be made in the callback code.

push @commands, Term::CLI::Command->new(
    name => 'cp',
    summary => 'copy files',
    description => "Copy files. The last argument in the\n"
                .  "list is the destination.\n",
    arguments => [
        Term::CLI::Argument::Filename->new( name => 'path',
            min_occur => 2,
            max_occur => 0
        ),
    ],
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;
        my @src = @{$args{arguments}};
        my $dst = pop @src;

        say "command:     ".$cmd->name;
        say "source:      ".join(', ', @src);
        say "destination: ".$dst;

        return %args;
    }
);

Example:

bash$ perl tutorial/example_10_cp_command.pl

[Welcome to BSSH]
bssh> cp
ERROR: need at least 2 'path' arguments
bssh> cp foo bar baz
command:     cp
source:      foo, bar
destination: baz
bssh> cp -r foo
command:     cp
source:      -r
destination: foo
bssh> ^D
-- exit: 0

Note that this setup does not recognise options, so all options will be passed as regular arguments.

The sleep command (single integer argument)

From THE BSSH CONCEPT section above:

    sleep seconds

This is an almost trivial implementation:

push @commands, Term::CLI::Command->new(
    name => 'sleep',
    summary => 'sleep for I<time> seconds',
    description => "Sleep for I<time> seconds.\n"
                .  "Report the actual time spent sleeping.\n"
                .  "This number can be smaller than I<time>\n"
                .  "in case of an interruption (e.g. INT signal).",
    arguments => [
        Term::CLI::Argument::Number::Int->new( name => 'time',
            min => 1, inclusive => 1
        ),
    ],
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;

        my $time = $args{arguments}->[0];

        say "-- sleep: $time";

        # Make sure we can interrupt the sleep() call.
        my $slept = do {
            local($::SIG{INT}) = local($::SIG{QUIT}) = sub {
                say STDERR "(interrupted by $_[0])";
            };
            sleep($time);
        };

        say "-- woke up after $slept sec", $slept == 1 ? '' : 's';
        return %args;
    }
);

The Term::CLI::Argument::Number::Int allows us to set a minimum and maximum value (and whether or not the boundaries are included in the allowed range). Our time to sleep should obviously be a positive integer.

See it in action:

bash$ perl tutorial/example_11_sleep_command.pl

[Welcome to BSSH]
bssh> help sleep
  Usage:
    sleep time

  Description:
    Sleep for time seconds. Report the actual time spent sleeping. This number
    can be smaller than time in case of an interruption (e.g. INT signal).
bssh> sleep 3
-- sleep: 3
-- woke up after 3 secs
bssh> sleep 30
-- sleep: 30
^C(interrupted by INT)
-- woke up after 5 secs
bssh> ^D
-- exit: 0

SUB-COMMANDS

You may have noticed that so far, we've only added commands with arguments. But what if we want to implement something like:

    show { load|clock|terminal }

Well, as it turns out, Term::CLI::Command(3p) can handle that as well: instead of specifying arguments in the constructor, you can specify commands. Just like for Term::CLI, the commands attribute takes a reference to an array of Term::CLI::Command objects.

The show command

The code for the show command looks almost trivial:

push @commands, Term::CLI::Command->new(
    name => 'show',
    summary => 'show system properties',
    description => "Show some system-related information,\n"
                .  "such as the system clock or load average.",
    commands => [
        Term::CLI::Command->new( name => 'clock',
            summary => 'show system time',
            description => 'Show system time and date.',
            callback => sub {
                my ($self, %args) = @_;
                return %args if $args{status} < 0;
                say scalar(localtime);
                return %args;
            },
        ),
        Term::CLI::Command->new( name => 'load',
            summary => 'show system load',
            description => 'Show system load averages.',
            callback => sub {
                my ($self, %args) = @_;
                return %args if $args{status} < 0;
                system('uptime');
                $args{status} = $?;
                return %args;
            },
        ),
        Term::CLI::Command->new( name => 'terminal',
            summary => 'show terminal information',
            description => 'Show terminal information.',
            callback => sub {
                my ($self, %args) = @_;
                return %args if $args{status} < 0;
                my ($rows, $cols)
                    = $self->root_node->term->get_screen_size;
                say "type $ENV{TERM}; rows $rows; columns $cols";
                $args{status} = 0;
                return %args;
            },
        ),
    ],
);

Adding this to our ever-growing bssh code, we get:

bash$ perl tutorial/example_12_show_command.pl

[Welcome to BSSH]
bssh> help show
  Usage:
    show {clock|load|terminal}

  Description:
    Show some system-related information, such as the system clock or load
    average.

  Sub-Commands:
    show clock               show system time
    show load                show system load
    show terminal            show terminal information
bssh> show clock
Wed Feb 21 14:21:56 2018
bssh> show load
 14:21:59 up 1 day, 15:30,  1 user,  load average: 0.19, 0.33, 0.40
bssh> show terminal
type gnome-256color; rows 25; columns 80
bssh> ^D
-- exit: 0

The set command

The specification says:

    set verbose bool

    set delimiters string

Code:

push @commands, Term::CLI::Command->new(
    name => 'set',
    summary => 'set CLI parameters',
    description => 'Set various CLI parameters.',
    commands => [
        Term::CLI::Command->new(
            name => 'delimiters',
            summary => 'set word delimiter(s)',
            description =>
                'Set the word delimiter(s) to I<string>.',
            arguments => [
                Term::CLI::Argument::String->new(name => 'string')
            ],
            callback => sub {
                my ($self, %args) = @_;
                return %args if $args{status} < 0;
                my $delimiters = $args{arguments}->[0];
                $self->root_node->word_delimiters($delimiters);
                say "Delimiters set to [$delimiters]";
                return %args;
            }
        ),
        Term::CLI::Command->new(
            name => 'verbose',
            summary => 'set verbose flag',
            description =>
                'Set the verbose flag for the program.',
            arguments => [
                Term::CLI::Argument::Bool->new(name => 'bool',
                    true_values  => [qw( 1 true on yes ok )],
                    false_values => [qw( 0 false off no never )],
                )

            ],
            callback => sub {
                my ($self, %args) = @_;
                return %args if $args{status} < 0;
                my $bool = $args{arguments}->[0];
                say "Setting verbose to $bool";
                return %args;
            }
        ),
    ],
);

This shows the use of Term::CLI::Argument::Bool (set verbose), and the use of alternative delimiters (set delimiters).

Results for set verbose:

bash$ perl tutorial/example_13_set_command.pl

[Welcome to BSSH]
bssh> help set
  Usage:
    set {delimiters|verbose}

  Description:
    Set various CLI parameters.

  Sub-Commands:
    set delimiters string    set word delimiter(s)
    set verbose bool         set verbose flag
bssh> set verbose o
ERROR: arg#1, 'o': ambiguous boolean value (matches [on, ok]
and [off]) for 'bool'
bssh> set verbose t
Setting verbose to 1

Results for set delimiters:

bash$ perl tutorial/example_13_set_command.pl

[Welcome to BSSH]
bssh> set delim ';,'
Delimiters set to [;,]
bssh> show clock
ERROR: unknown command 'show clock'
bssh> show;clock
Wed Mar 14 23:44:49 2018
bssh> make;love,now
making love now
bssh> exit;0
-- exit: 0

Combining arguments and sub-commands

A Term::CLI::Command object can have both arguments and (sub-)commands as well. If this is the case, the parser expects the arguments before the sub-commands, and there can be no variable number of arguments.

This technique can be used to specify arguments that are common to sub-commands (the interface command), or to create syntactic sugar (the do command).

Syntactic sugar: the do command

The specification says:

    do {something|nothing} while {working|sleeping}

Code:

push @commands, Term::CLI::Command->new(
    name => 'do',
    summary => 'Do I<action> while I<activity>',
    description => "Do I<action> while I<activity>.\n"
                .  "Possible values for I<action> are:\n"
                .  "C<nothing>, C<something>.\n"
                .  "Possible values for I<activity> are:\n"
                .  "C<sleeping>, C<working>.",
    arguments => [
        Term::CLI::Argument::Enum->new( name => 'action',
            value_list => [qw( something nothing )],
        ),
    ],
    commands => [
        Term::CLI::Command->new(
            name => 'while',
            arguments => [
                Term::CLI::Argument::Enum->new( name => 'activity',
                    value_list => [qw( eating sleeping )],
                ),
            ],
        ),
    ],
    callback => sub {
        my ($cmd, %args) = @_;
        return %args if $args{status} < 0;
        my @args = @{$args{arguments}};
        say "doing $args[0] while $args[1]";
        return %args;
    }
);

Common argument(s): the interface command

The specification says:

    interface iface {up|down}

The iface argument is used by both sub-commands.

Code:

push @commands, Term::CLI::Command->new(
    name => 'interface',
    summary => 'Turn I<iface> up or down',
    description => "Turn the I<iface> interface up or down.",
    arguments => [
        Term::CLI::Argument::String->new( name => 'iface' )
    ],
    commands => [
        Term::CLI::Command->new(
            name => 'up',
            summary => 'Bring I<iface> up',
            description => 'Bring the I<iface> interface up.',
            callback => sub {
                my ($cmd, %args) = @_;
                return %args if $args{status} < 0;
                my @args = @{$args{arguments}};
                say "bringing up $args[0]";
                return %args;
            }
        ),
        Term::CLI::Command->new(
            name => 'down',
            summary => 'Shut down I<iface>',
            description => 'Shut down the I<iface> interface.',
            callback => sub {
                my ($cmd, %args) = @_;
                return %args if $args{status} < 0;
                my @args = @{$args{arguments}};
                say "shutting down $args[0]";
                return %args;
            }
        ),
    ],
);

With the above two additions, we have:

bash$ perl tutorial/example_14_sub_cmd_and_args.pl

[Welcome to BSSH]
bssh> help
  Commands:
    cp path1 path2 ...                           copy files
    do action while activity                     do action while activity
    echo arg ...                                 print arguments to stdout
    exit excode                                  exit bssh
    help cmd ...                                 show help
    interface iface {up|down}                    turn iface up or down
    ls arg ...                                   list file(s)
    make target when                             make target at time when
    set {delimiters|verbose}                     set CLI parameters
    show {clock|load}                            show system properties
    sleep time                                   sleep for time seconds
bssh> do something wh s
doing something while sleeping
bssh> i eth0 u
bringing up eth0
bssh> i eth0 d
shutting down eth0
bssh> ^D
-- exit: 0

BONUS POINTS: A debug COMMAND

The fun thing of nesting commands is that we can easily implement this:

use Data::Dumper;

push @commands, Term::CLI::Command->new(
    name => 'debug',
    usage => 'B<debug> I<cmd> ...',
    summary => 'debug commands',
    description => "Print some debugging information regarding\n"
                .  "the execution of a command.",
    commands => [ @commands ],
    callback => sub {
        my ($cmd, %args) = @_;
        my @args = @{$args{arguments}};
        say "# --- DEBUG ---";
        my $d = Data::Dumper->new([\%args], [qw(args)]);
        print $d->Maxdepth(2)->Indent(1)->Terse(1)->Dump;
        say "# --- DEBUG ---";
        return %args;
    }
);

Here, we basically added a debug command that takes any other command structure as a sub-command and, after the sub-command has executed, will print some status information.

bash$ perl tutorial/example_15_debug_command.pl

[Welcome to BSSH]
bssh> debug <TAB><TAB>
cp     echo   exit   ls     make   set    show   sleep
bssh> debug echo hi
hi
# --- DEBUG ---
{
  'error' => '',
  'status' => 0,
  'arguments' => [
    'hi'
  ],
  'command_path' => [
    'Term::CLI=HASH(0x55e95ae02e20)',
    'Term::CLI::Command=HASH(0x55e95b0c3998)',
    'Term::CLI::Command=HASH(0x55e95b03f780)'
  ],
  'options' => {}
}
# --- DEBUG ---
bssh> exit
-- exit: 0

Note the addition of the static usage line, because the autogenerated usage line is too long (it lists every possible sub-command):

bash$ perl tutorial/example_15_debug_command.pl

[Welcome to BSSH]
bssh> help
  Commands:
    cp path1 path2 ...                           copy files
    debug cmd ...                                debug commands
    [...]
bssh> help debug
  Usage:
    debug cmd ...

  Description:
    Print some debugging information regarding the execution of cmd.

  Sub-Commands:
    debug cp path1 path2 ...                     copy files
    debug do action while activity               Do action while activity
    debug echo arg ...                           print arguments to stdout
    debug exit excode                            exit bssh
    debug help cmd ...                           show help
    debug interface iface {down|up}              Turn iface up or down
    debug ls arg ...                             list file(s)
    debug make target when                       make target at time when
    debug set {delimiters|verbose}               set CLI parameters
    debug show {clock|load}                      show system properties
    debug sleep time                             sleep for time seconds

Caveat on parent

Note that this construction is not entirely without consequences, though: adding a Term::CLI::Command to another Term::CLI::Command or a Term::CLI object (or any object that consumes the Term::CLI::Role::CommandSet role) will cause the Term::CLI::Command object's parent attribute to be set.

At this moment, the parent attribute is only used to find the root_node, but this may change in the future.

To ensure the hierarchy still makes sense then, add the @commands to the debug command before adding them to the Term::CLI object.

And, yes, you can in principle do this:

my $debug = Term::CLI::Command->new( name => 'debug', ... );
push @commands, $debug;
$debug->add_command(@commands);
$term->add_command(@commands);

This would give you a debug command that can debug itself: debug debug debug ... (but why would you want that!?).

ADDING OPTIONS

You may have noticed that the output of the debug command above showed an options key that points to a HashRef. This contains valid command line options from the input. To have the parsing and completion code recognise command line options, simply pass an options parameter to the Term::CLI::Command constructor call:

push @commands, Term::CLI::Command->new(
    name => 'show',
    options => [ 'verbose|v!' ],
    commands => [
        Term::CLI::Command->new( name => 'clock',
            options => [ 'timezone|tz|t=s' ],
            callback => \&do_show_clock,
        ),
        Term::CLI::Command->new( name => 'load',
            callback => \&do_show_uptime,
        ),
    ],
);

sub do_show_clock {
    my ($self, %args) = @_;
    return %args if $args{status} < 0;
    my $opt = $args{options};

    local($::ENV{TZ});
    if ($opt->{timezone}) {
        $::ENV{TZ} = $opt->{timezone};
    }
    say scalar(localtime);
    return %args;
}

sub do_show_uptime {
    my ($self, %args) = @_;
    return %args if $args{status} < 0;
    system('uptime');
    $args{status} = $?;
    return %args;
}

The value should be an ArrayRef with the allowed options in Getopt::Long(3p) format. The Term::CLI code will turn on bundling (allow grouping of single letter options, i.e. -a and -b can be written as -ab) and require_order (no mixing of options and arguments).

Above, we've added a negatable --verbose option to the show command, and a specific --timezone option to the clock sub-command.

The help text for show will now include the verbose options (note that both --verbose and --no-verbose are included):

bash$ perl tutorial/example_16_options.pl

[Welcome to BSSH]
bssh> help show
Usage:
    show [--verbose|--no-verbose] [-v] {clock|load|terminal}

Description:
    Show some system-related information, such as the system clock
    or load average.

  Sub-Commands:
    show clock       show system time
    show load        show system load
    show terminal    show terminal information

Similarly, the help text for show clock will include the time zone option:

bssh> help show clock
Usage:
    show clock [--timezone=s] [--tz=s] [-ts]

Description:
    Show system time and date.

This will allow the following commands:

bssh> show clock
Wed Feb 21 15:40:46 2018

bssh> show --verbose clock --tz=UTC
Wed Feb 21 14:41:02 2018

bssh> show clock -t UTC
Wed Feb 21 14:41:05 2018

However, the --verbose option cannot be specified after clock:

bssh> show clock --verbose --tz=UTC
ERROR: Unknown option: verbose

Note, though, that the --verbose option after show is recorded in the options hash when do_show_clock is called:

bssh> debug show --verbose clock --tz CET
Tue Feb 21 14:41:45 2018
# --- DEBUG ---
{
  'options' => {
    'verbose' => 1,
    'timezone' => 'CET'
  },
  'error' => '',
  'arguments' => [],
  'command_path' => [
    'Term::CLI=HASH(0x55efdbf10bc8)',
    'Term::CLI::Command=HASH(0x55efdc040a28)',
    'Term::CLI::Command=HASH(0x55efdc040fe0)',
    'Term::CLI::Command=HASH(0x55efdc041070)'
  ],
  'status' => 0
}
# --- DEBUG ---

If you want --verbose to be valid after clock, you'll need to specify it explicitly in its options:

Term::CLI::Command->new( name => 'clock',
    options => [ 'verbose|v!', 'timezone|tz|t=s' ],
    ...
),

SIGNAL HANDLING

As you may have noticed, we disable the interrupt (INT) signal in the beginning of the program. This is because we want Ctrl-C to abort the input line, but not exit the program.

There are other signals that can be entered via the command line, namely QUIT and TSTP (suspend).

Normally, Ctrl-\ will generate a QUIT signal (3) to the application. You usually don't want this during keyboard input (having an accidental Ctrl-\ kill your session can be annoying), so Term::CLI will disable this for you already.

However, if you run any of the previous examples and enter Ctrl-Z, you'll notice that the program suspends and you are dropped back into the shell you started from.

What if you don't want to suspend on Ctrl-Z, but add a suspend command instead?

The suspend command

The tutorial/example_17_suspend.pl file adds the following code:

$SIG{INT} = $SIG{TSTP} = 'IGNORE';

...

push @commands, Term::CLI::Command->new(
    name => 'suspend',
    summary => 'suspend the program',
    description => "Suspend the program by sending a C<TSTP>\n"
                .  "signal and return control to the shell.",
    callback => sub {
        my ($cmd, %args) = @_;
        local($SIG{TSTP}) = 'DEFAULT';
        kill 'TSTP', $$;
        return %args;
    }
);

Now do this:

  1. perl tutorial/example_16_options.pl

  2. Enter Ctrl-Z; this should suspend the program.

  3. Type fg + ENTER to continue again.

Compare that to the version with suspend:

  1. perl tutorial/example_17_suspend.pl

  2. Enter Ctrl-Z; nothing should happen (unless you have Term::ReadLine::Perl loaded).

  3. Enter the suspend command. This should suspend the program.

  4. Type fg + ENTER to continue again.

For more details on the dirty secrets of signal handling, see the SIGNAL HANDLING section in Term::CLI::ReadLine(3p).

DYNAMIC COMPLETION LISTS

Let's look at the interface command again (see above).

The iface argument is defined as a simple Term::CLI::Argument::String, and so has no possibilities for completion.

What if we want to enable completion for this argument, though? Well, the obvious choice is Term::CLI::Argument::Enum, which we've seen before (See make and do above).

We can query the system for a list of the available interfaces at object construction time, but what if we run this on a laptop where devices can be hot-plugged (USB dongles, etc.)?

Well, this is where Term::CLI::Argument::Enum has a trick up its sleeve. Instead of an ArrayRef, we can provide it with a CodeRef which will be called when the list of possible values has to be expanded.

So, let's rewrite the interface command definition. Instead of:

arguments => [
    Term::CLI::Argument::String->new( name => 'iface' )
],

We specify:

arguments => [
    Term::CLI::Argument::Enum->new(
        name => 'iface',
        value_list => sub {
            my $if_t = readpipe('ip link show 2>/dev/null') // '';
            my @if_l = $if_t =~ /^ \d+ : \s (\S+): \s/gxms;
            return \@if_l;
        }
    ),
],

This results in example_18_dynamic_enum.pl:

bash$ perl tutorial/example_18_dynamic_enum.pl

[Welcome to BSSH]
bssh> interface <TAB><TAB>
lo        virbr0    virbr1    wlp113s0

Now, when I plug in my USB-C dongle with an Ethernet adapter, I get:

bssh> interface <TAB><TAB>
enp0s13f0u3u1  lo   virbr0    virbr1   wlp113s0

Neat, huh?

In combination with the cache_values flag, dynamic value lists can also be used to generate the value list once on demand (e.g. by querying a remote database) without affecting startup time of the application itself.

See Term::CLI::Argument::Enum for more details.

DEALING WITH HISTORY

By default, the Term::CLI objects do not try to read or write to history files, so you will have to tell the application to do so explicitly. Fortunately, that's not hard:

$term->read_history();

while (defined (my $l = $term->readline)) {
    ...
}

$term->write_history()
    or warn "cannot write history: ".$term->error."\n";

(Note that we don't raise a warning if we cannot read the history file: you don't want to get a warning if you run the application for the first time.)

By default, if the application is named bssh, the history will be read/written to/from ~/.bssh_history, and Term::CLI will remember 1000 lines of input history.

See the History Control section in the Term::CLI documentation for more information on how to change the defaults.

Ensuring history is saved on exit

The above code will not save the command history if you call exit from a callback function. The previously created exit command (see above) calls the execute_exit callback, which calls exit(). As a result, the line with $term->write_history() is never reached.

There are several approaches to fix this, the most recommended is to use the Term::CLI's cleanup attribute:

my $term = Term::CLI->new(
    ...,
    cleanup => sub {
        my ($term) = @_;
        $term->write_history() or
            or warn "cannot write history: ".$term->error."\n";
    }
);

while ( defined(my $line = $term->readline) ) {
    $term->execute_line($line);
}
exit 0;

This ensures that the command history will be saved before the $term object is destroyed.

For completeness' sake, here are a number of alternative options, YMMV:

  1. Add a global flag that is checked within the loop:

    $::global_exit = undef;
    
    sub execute_exit {
        my ($cmd, $excode) = @_;
        $excode //= 0;
        say "-- exit: $excode";
        $::global_exit = $excode;
    }
    
    ...
    
    while (defined (my $line = $term->readline)) {
        $term->execute_line($line);
        last if defined $::global_exit;
    }
    $term->write_history()
        or warn "cannot write history: ".$term->error."\n";
    
    exit($::global_exit // 0);

    This has the disadvantage of creating a global variable. :-(

  2. Return an exit flag through execute:

    push @commands, Term::CLI::Command->new(
        name => 'exit',
        callback => sub {
            my ($cmd, %args) = @_;
            return %args if $args{status} < 0;
            my $excode = execute_exit($cmd, @{$args{arguments}});
            return (%args, exit => $excode);
        },
        arguments => [
            Term::CLI::Argument::Number::Int->new(  # Integer
                name => 'excode',
                min => 0,             # non-negative
                min_occur => 0,       # occurrence is optional
            ),
        ],
    );
    
    sub execute_exit {
        my ($cmd, $excode) = @_;
        $excode //= 0;
        say "-- exit: $excode";
        return $excode;
    }
    
    ...
    
    my %ret;
    while (defined (my $line = $term->readline)) {
        %ret = $term->execute_line($line);
        last if defined $ret{exit};
    }
    $term->write_history()
        or warn "cannot write history: ".$term->error."\n";
    
    exit($ret{exit} // 0);

    This is better, since it doesn't rely on global variables, but it still depends on "magic" data to trigger delayed actions.

  3. Write history in the callback:

    sub execute_exit {
        my ($cmd, $excode) = @_;
        my $term = $cmd->root_node;
        $term->write_history()
            or warn "cannot write history: ".$term->error."\n";
        $excode //= 0;
        say "-- exit: $excode";
        exit $excode;
    }
    
    ...
    
    while (defined (my $line = $term->readline)) {
        $term->execute_line($line);
    }
    
    execute_exit($term, 0);

    This is preferable to the previous two, since it doesn't require global flags or magic values. It also allows us to call execute_exit at the end of the loop.

    Still, this is not perfect, as you have to remember to call execute_exit instead of exit in other places of your program as well. Some of these places may not have access to a valid Term::CLI or Term::CLI::CommandSet object, so this adds further complications.

  4. Use an END or defer block.

    This will ensure that the history writing will occur before the program exits.

    Using an END block:

    my $term = Term::CLI->new( ... );
    
    ...
    
    END {
        if (defined $term) {
            $term->write_history()
                or warn "cannot write history: ".$term->error."\n";
        }
    }

    Note that this still requires you to make the $term variable visible in the package scope (i.e. it cannot be a lexical variable in a function).

    It's also possible to use the defer keyword (see Syntax::Keyword::Defer):

    my $term = Term::CLI->new( ... );
    
    defer {
        $term->write_history() or
            or warn "cannot write history: ".$term->error."\n";
    }
    
    ...

    While tempting, this means you have to be careful to place the defer in the correct scope:

    sub initialise_term {
        my $term = Term::CLI->new( ... );
        defer {
            $term->write_history() or
                or warn "cannot write history: ".$term->error."\n";
        }
        return $term;
    }
    
    my $term = initialise_term();
    
    while ( defined(my $line = $term->readline) ) {
        $term->execute_line($line);
    }
    exit 0;

    The above will not have the desired effect of writing the history to file upon exit.

COMPARISON WITH OTHER IMPLEMENTATIONS

Here are some examples of how you might go about it without Term::CLI. We've only decided to imlement a few of the simpler commands.

Naive implementation

The "naive" implementation uses no fancy modules, just a loop reading from STDIN and some explicit if statements matching the commands:

use 5.014_001;
use warnings;
use Text::ParseWords qw( shellwords );
use Term::ReadLine;

print "bssh> ";
while (<>) {
    next if /^\s*(?:#.*)?$/; # Skip comments and empty lines.
    evaluate_input($_);
} continue {
    print "bssh> ";
}
print "\n";
execute_exit('exit', 0);

sub evaluate_input {
    my $cmd_line = shift;
    my @cmd_line = shellwords($cmd_line);
    if (!@cmd_line) {
        say STDERR "cannot parse input (unbalanced quote?)";
        return;
    }
    return execute_cp(@cmd_line)    if $cmd_line[0] eq 'cp';
    return execute_echo(@cmd_line)  if $cmd_line[0] eq 'echo';
    return execute_exit(@cmd_line)  if $cmd_line[0] eq 'exit';
    return execute_ls(@cmd_line)    if $cmd_line[0] eq 'ls';
    return execute_make(@cmd_line)  if $cmd_line[0] eq 'make';
    return execute_sleep(@cmd_line) if $cmd_line[0] eq 'sleep';
    say STDERR "unknown command: '$cmd_line[0]'";
}

sub execute_cp { ... }
sub execute_ls { ... }
sub execute_echo { ... }
sub execute_exit { ... }
sub execute_sleep { ... }

sub execute_make {
    my ($cmd, @args) = @_;
    if (@args != 2) {
        say STDERR "$cmd: need exactly two arguments";
        return;
    }
    if ($args[0] !~ /^(love|money)$/) {
        say STDERR "$cmd: unknown target '$args[0]'";
        return;
    }
    elsif ($args[1] !~ /^(now|later|never|forever)$/) {
        say STDERR "$cmd: unknown period '$args[0]'";
        return;
    }
    say "making $args[0] $args[1]";
}

(This full script can be found in as examples/simple_cli.pl in the source distribution.)

This performs the basic actions, but does not offer anything else.

IMPLEMENTATION WITH TERM::READLINE

Replacing the REPL above by a Term::ReadLine(3p) construction, we get:

use 5.014_001;
use warnings;
use Text::ParseWords qw( shellwords );
use Term::ReadLine;

my $term = Term::ReadLine->new('bssh');
while (defined(my $cmd_line = $term->readline('bssh> '))) {
    evaluate_input($_);
}
execute_exit('exit', 0);

(This script can be found as examples/readline_cli.pl in the source distribution.)

This adds a few nice features:

  • Input editing

  • History

But lacks some others:

  • Command line completion

    By default Term::ReadLine performs file name completion, so e.g. the make command will show file name completions, not the valid targets.

    It's possible to set up custom completion routines, but it's not trivial.

  • Command and parameter abbreviation

    You can't write ex 0, or m l a.

    To support abbreviations, you'd have to add prefix matching in the evaluate_input and various execute_* routines, making sure to do something sensible with ambiguous prefixes (e.g. throwing an error). You'd have to do that for every sub-command/parameter, though.

  • Built-in help

SEE ALSO

Term::CLI::Intro(3p).

Getopt::Long(3p), Term::CLI(3p), Term::CLI::Argument(3p), Term::CLI::Argument::Bool(3p), Term::CLI::Argument::Enum(3p), Term::CLI::Argument::FileName(3p), Term::CLI::Argument::Number(3p), Term::CLI::Argument::Number::Float(3p), Term::CLI::Argument::Number::Int(3p), Term::CLI::Argument::String(3p), Term::CLI::Command(3p), Term::CLI::Role::CommandSet(3p), Term::ReadLine(3p).

FILES

The following files in the source distribution illustrate the examples above:

examples/simple_cli.pl

The "naive" implementation with a simple read loop.

examples/readline_cli.pl

The simple Term::ReadLine implementation that adds command line editing, filename completion, and command history.

tutorial/term_cli.pl

The full-blown Term::CLI implementation with all of the features of tutorial/readline_cli.pl, adding all the goodness.

tutorial/example_*.pl

The tutorial code.

AUTHOR

Steven Bakker <sbakker@cpan.org>

COPYRIGHT AND LICENSE

Copyright (c) 2018 Steven Bakker

This module is free software; you can redistribute it and/or modify it under the same terms as Perl itself. See "perldoc perlartistic."

This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.