App::Easer::V2 Tutorial For 2.008

Version 2.008 introduced some new features in the general V2 track, while still being backwards compatible. Reference documentation is becoming dense, so here we go with a tutorial focused on a real-world usage case.

It can be argued that there are too many ways to use App::Easer. This is a true statement: an application can range from a single file with multiple commands up to a whole tree of modules, each encapsulating one; even in this latter case, there are multiple ways to use the module, depending on whether you consider some of the stuff pure data or need some more logic to control it.

Some of the concepts are common to all different ways, so this document will mostly focus on them; other documents will address usage in one way or another.

The point of App::Easer is to facilitate development of command-line interfaces, with some common features that I've come to desire over and over in time:

  • Collect command options from multiple sources, like command-line arguments, environment variables, files, possibly other sources like e.g. databases or remote stuff accessible through a HTTP call. Oh, and set a default value as a last resort, of course.

  • Automate generation of documentation for options, based on their declaration.

  • Structure the application as a tree of commands, instead of cramming wildly different behaviours through the usage of command-line options.

  • Ease provision and access to a command's documentation (including options as described above).

  • Allow for fatpacking the module, so that it's possible to produce a standalone program that can be easily carried around.

It might be slightly overkill for a one-off program, but it can be good to include it from the beginning if you anticipate the need to pack multiple related commands in a single suite down the road.

This tutorial is focused on providing concrete examples of using App::Easer, in increasing passes where we leverage more and more functionality.

Pass 1: Basic Program

We want to code a multi-level command-line interface that manages key-value data in a JSON file:

  • The file name is provided through command-line option --db | --json-db | -d, which can also be provided through environment variable CRUD_DB

  • CRUDS operations are provided through sub-commands create, retrieve, update, delete, and list. Each has its own specific options.

Our initial program is the following:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use autodie qw< open readline close >;
use JSON::PP ();

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< crud >],
   help    => 'A command for CRUD operations over a JSON file',
   description => 'To be written...',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt      => 'db|json-db|d=s',
         environment => 'CRUD_DB',
         help        => 'path to the JSON file for CRUD operations',
      }
   ],
   children => [
      {
         aliases => [qw< create >],
         help    => 'create a name/value pair',
         description => 'Need to provide a name, complains if exists already',
         options => [
            {
               getopt => 'name|n=s',
               help   => 'name of key to create',
            },
            {
               getopt => 'value|v=s',
               help   => 'value to set for the key',
            }
         ],
         execute => sub ($self) {
            my $name = $self->config('name') // die "no name provided\n";
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data;
            eval { $data = load_json($dbpath) };
            die "entry for <$name> already exists\n"
               if exists($data->{$name});
            $data->{$name} = $self->config('value') // '';
            save_json($dbpath, $data);
            return 0;
         },
      },
      {
         aliases => [qw< retrieve >],
         help    => 'get the value associated to a name',
         description => 'Need to provide a name',
         options => [
            {
               getopt => 'name|n=s',
               help   => 'name of key to retrieve',
            },
         ],
         execute => sub ($self) {
            my $name = $self->config('name') // die "no name provided\n";
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            die "entry for <$name> does not exists\n"
               unless exists($data->{$name});
            print {*STDOUT} $data->{$name} // '';
            return 0;
         },
      },
      {
         aliases => [qw< update >],
         help    => 'update value for a name',
         description => 'Need to provide a name, complains if not present',
         options => [
            {
               getopt => 'name|n=s',
               help   => 'name of key to update',
            },
            {
               getopt => 'value|v=s',
               help   => 'value to set for the key',
            }
         ],
         execute => sub ($self) {
            my $name = $self->config('name') // die "no name provided\n";
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            die "entry for <$name> does not exists\n"
               unless exists($data->{$name});
            $data->{$name} = $self->config('value') // '';
            save_json($dbpath, $data);
            return 0;
         },
      },
      {
         aliases => [qw< delete >],
         help    => 'delete name or names (based on regex)',
         description => 'Need to provide name or name regular expression',
         options => [
            {
               getopt => 'name|n=s',
               help   => 'name of key to delete',
            },
            {
               getopt => 'name-rx|r=s',
               help   => 'regular expression for name(s) to delete',
            },
         ],
         execute => sub ($self) {
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            
            if (defined(my $name = $self->config('name'))) {
               delete($data->{$name});
            }
            elsif (defined(my $rx = $self->config('name-rx'))) {
               for my $name (keys($data->%*)) {
                  delete($data->{$name}) if $name =~ m{$rx};
               }
            }
            else {
               die "no clue what should be deleted\n";
            }

            save_json($dbpath, $data);
            return 0;
         },
      },
      {
         aliases => [qw< list >],
         help    => 'list names of available name/value pairs',
         description => '',
         options => [ ],
         execute => sub ($self) {
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            say {*STDOUT} $_ for sort { $a cmp $b } keys($data->%*);
            exit 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

sub load_json ($path) {
   open my $fh, '<:raw', $path;
   local $/;
   return JSON::PP::decode_json(<$fh>);
}

sub save_json ($path, $data) {
   state $encoder = JSON::PP->new->ascii->canonical->pretty;
   open my $fh, '>:raw', $path;
   print {$fh} $encoder->encode($data);
}

The structure is the following:

#!/usr/bin/env perl

# usual preamble with use-s and so on
use strict;
use warnings;
# ...

# what's needed to use App::Easer::V2
use App::Easer::V2 qw< run >;
my $app = {  ... }; # the whole application specification
exit(run($app, $0, @ARGV) // 0);

# ... other supporting functions etc.

The structure of command/subcommands is provided by means of the children key at the topmost level. You don't see them but three commands are always added by default, i.e. help (with an alias usage), commands, and tree.

Automatic commands help, usage, commands, and tree

Let's run the program without any option:

$ crud
A command for CRUD operations over a JSON file

Options:
             db: path to the JSON file for CRUD operations
                 command-line: string, value is required
                               --db <value>
                               --json-db <value>
                               -d <value>
                  environment: CRUD_DB

Sub-commands:
         create: create a name/value pair
       retrieve: get the value associated to a name
         update: update value for a name
         delete: delete name or names (based on regex)
           list: list names of available name/value pairs
           help: print a help command
                 (also as: usage)
       commands: list sub-commands
           tree: print sub-commands in a tree

By default, any command with children is assumed to just facilitate calling them, so it makes sense that its default behaviour is to call its own usage sub-command. The output would be the same by calling usage directly as a sub-command:

$ crud usage
# ... same output as above

The usage command is actually an alias for the help. Altough they are coalesced together, they actually behave slightly differently, with help being more verbose and also printing out whatever is provided in the description:

# crud help
A command for CRUD operations over a JSON file

Description:
    To be written...

Options:
             db: path to the JSON file for CRUD operations
... same as usage from here...

Child commands don't get automatic sub-commands by default, although it's possible by means of option force_auto_children in the command's specification hash.

Options

Let's look at relevant keys for options collection:

my $app = {
   # ...
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt      => 'db|json-db|d=s',
         environment => 'CRUD_DB',
         help        => 'path to the JSON file for CRUD operations',
      }
   ],
   children => [
      {
         # child 'create'
         options => [
            {
               getopt => 'name|n=s',
               help   => 'name of key to create',
            },
            {
               getopt => 'value|v=s',
               help   => 'value to set for the key',
            }
         ],
         # ...
      },
      {
         # child 'retrieve'
         options => [
            {
               getopt => 'name|n=s',
               help   => 'name of key to retrieve',
            },
         ],
         # ...
      },
      # other children are pretty much the same...
   ],
};

Key sources allows setting how options are collected. There are multiple ways of providing them; using string v2.008 is a shorthand to adopt the new basic behaviour introduced in version 2.008 and is equivalent to this:

sources => {
   current => [qw< +CmdLine +Environment +Default +ParentSlices >],
   final   => [],
}

This means that, at any command's level, command-line options take precedence over anything else, followed environment variables and defaults. Source +ParentSlices is a bit special, in that it gathers options collected by the parent command, preserving whatever priority they had but introducing them after whatever has been collected so far.

This is actually not much of a problem in this initial program, because the option in the parent command (i.e. --db) is not replicated elsewhere, so there is no need to manage any priority between parent and child commands. This will change later in this tutorial.

To get the options collected in the new way, it's also necessary to set key config_hash_key to 2.008. This is necessary because version 2.8 is backwards compatible with previous versions and this way of collecting options is new.

Options are specified in an array reference provided via key options. This allows setting different ways of collecting the option, like this:

{
   name        => 'Foo',
   getopt      => 'foo|f=s',
   environment => 'MYAPP_FOO',
   default     => 'bar or baz, folks!',
   help        => 'something to print in the help',
}

Providing a name is optional; it is extracted automatically from getopt or environment if not provided. getopt is used with Getopt::Long; environment allows setting the environment variable to get the value, if present. default provides a default value.

Each key is used by one of the sources, respectively +CmdLine, +Environment, and +Default.

Execution

In each command, key execute allows setting a callback for actually running the command. After App::Easer determines which command should be run, it calls its execute callback; each run of the program only calls the execute for a single command.

Let's look at sub-command delete as an example:

{
   # ... sub-command delete...
   options => [
      {
         getopt => 'name|n=s',
         help   => 'name of key to delete',
      },
      {
         getopt => 'name-rx|r=s',
         help   => 'regular expression for name(s) to delete',
      },
   ],
   execute => sub ($self) {
      my $dbpath = $self->config('db') // die "no db provided\n";
      my $data = load_json($dbpath);
      
      if (defined(my $name = $self->config('name'))) {
         delete($data->{$name});
      }
      elsif (defined(my $rx = $self->config('name-rx'))) {
         for my $name (keys($data->%*)) {
            delete($data->{$name}) if $name =~ m{$rx};
         }
      }
      else {
         die "no clue what should be deleted\n";
      }

      save_json($dbpath, $data);
      return 0;
   },
},

The callback is provided a reference to the command, which can be used to retrieve collected option. Whatever option is available from the command itself or from the parent can be accessed via method config:

my $dbpath = $self->config('db') // die "no db provided\n";

if (defined(my $name = $self->config('name'))) { ...

It is also possible to get all options as a hash reference through method config_hash:

my $all_configs_hash = $self->config_hash;

This method uses whatever is set for config_hash_key to provide you the right data. To get the new way of collecting options, you must set it to string v2.008 as explained in the previous section.

Before closing, you might ask: where are non-option command line arguments? You get them calling residual_args, which returns a list:

my @non_option_arguments = $self->residual_args;

Pass 2: Inherited options

App::Easer supports two concepts of inheriting options in a child command from a parent command:

  • value inheritance: values for options collected in the parent are also available in the children. This behaviour is available by default (see previous section and souce +ParentSlices) because it seems just right, although it's possible to disable it;

  • definition inheritance: options definitions in the parent might be duplicated in the child.

The second kind of inheritance allows making your command-line more flexible for your users. As an example, let's see how we should call our program in the previous section to add a key/value pair in the managed file:

$ crud --file /path/to/file.db create --name foo --data bar

The user must remember to provide the --file option in the parent, then other options in the child. It would be easier to support this syntax too:

$ crud create --file /path/to/file.db --name foo --data bar

This makes the program do the right thing.

It is of course possible to just replicate the option's definition in the child command, but there's a better way that avoids repetition and increses modularity, like in the following second iteration of the whole program.

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use autodie qw< open readline close >;
use JSON::PP ();

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< crud >],
   help    => 'A command for CRUD operations over a JSON file',
   description => 'To be written...',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt      => 'db|json-db|d=s',
         environment => 'CRUD_DB',
         help        => 'path to the JSON file for CRUD operations',

         # Set the option as "inheritable" by children
         transmit    => 1,
      }
   ],
   children => [
      {
         aliases => [qw< create >],
         help    => 'create a name/value pair',
         description => 'Need to provide a name, complains if exists already',
         options => [
            'db',  # <-- inherit option definition from parent

            {
               getopt => 'name|n=s',
               help   => 'name of key to create',
            },
            {
               getopt => 'value|v=s',
               help   => 'value to set for the key',
            }
         ],
         execute => sub ($self) {
            my $name = $self->config('name') // die "no name provided\n";
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data;
            eval { $data = load_json($dbpath) };
            die "entry for <$name> already exists\n"
               if exists($data->{$name});
            $data->{$name} = $self->config('value') // '';
            save_json($dbpath, $data);
            return 0;
         },
      },
      {
         aliases => [qw< retrieve >],
         help    => 'get the value associated to a name',
         description => 'Need to provide a name',
         options => [
            'db',
            {
               getopt => 'name|n=s',
               help   => 'name of key to retrieve',
            },
         ],
         execute => sub ($self) {
            my $name = $self->config('name') // die "no name provided\n";
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            die "entry for <$name> does not exists\n"
               unless exists($data->{$name});
            print {*STDOUT} $data->{$name} // '';
            return 0;
         },
      },
      {
         aliases => [qw< update >],
         help    => 'update value for a name',
         description => 'Need to provide a name, complains if not present',
         options => [
            'db',
            {
               getopt => 'name|n=s',
               help   => 'name of key to update',
            },
            {
               getopt => 'value|v=s',
               help   => 'value to set for the key',
            }
         ],
         execute => sub ($self) {
            my $name = $self->config('name') // die "no name provided\n";
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            die "entry for <$name> does not exists\n"
               unless exists($data->{$name});
            $data->{$name} = $self->config('value') // '';
            save_json($dbpath, $data);
            return 0;
         },
      },
      {
         aliases => [qw< delete >],
         help    => 'delete name or names (based on regex)',
         description => 'Need to provide name or name regular expression',
         options => [
            'db',
            {
               getopt => 'name|n=s',
               help   => 'name of key to delete',
            },
            {
               getopt => 'name-rx|r=s',
               help   => 'regular expression for name(s) to delete',
            },
         ],
         execute => sub ($self) {
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            
            if (defined(my $name = $self->config('name'))) {
               delete($data->{$name});
            }
            elsif (defined(my $rx = $self->config('name-rx'))) {
               for my $name (keys($data->%*)) {
                  delete($data->{$name}) if $name =~ m{$rx};
               }
            }
            else {
               die "no clue what should be deleted\n";
            }

            save_json($dbpath, $data);
            return 0;
         },
      },
      {
         aliases => [qw< list >],
         help    => 'list names of available name/value pairs',
         description => '',
         options => [ 'db' ],
         execute => sub ($self) {
            my $dbpath = $self->config('db') // die "no db provided\n";
            my $data = load_json($dbpath);
            say {*STDOUT} $_ for sort { $a cmp $b } keys($data->%*);
            exit 0;
         },
      },
   ],
};

sub load_json ($path) {
   open my $fh, '<:raw', $path;
   local $/;
   return JSON::PP::decode_json(<$fh>);
}

sub save_json ($path, $data) {
   state $encoder = JSON::PP->new->ascii->canonical->pretty;
   open my $fh, '>:raw', $path;
   print {$fh} $encoder->encode($data);
}

exit(run($app, $0, @ARGV) // 0);

Where's the inheritance?

The are very little changes with respect to the previous iteration, so let's look at them in more detail:

my $app = {
   ...
   options => [
      {
         ...
         # Set the option as "inheritable" by children
         transmit    => 1,
      }
   ],
   children => [
      {
         ...
         options => [
            'db',  # <-- inherit option definition from parent
            ...

There are two halves to options definition inheritance: the parent marks an option as available for inheritance setting a true value for key transmit, and the child gets it by putting its name in the list of options (as opposed to a full hash-based definition).

Why transmit? Because +parent.

You might be wondering why setting options explicitly as transmit instead of providing them all and let the child command decide. This has to do with dealing with inheritance of many options all at a time.

If a child's options array has this:

{
   ...
   options => [
      '+parent',
      ...

it will inherit all options that are marked as transmit in the parent.

Inheritance might also be more fine-tuned by means of regular expressions in the child. Suppose that your program supports a list of options for connecting to a HTTP server and another list of options for connecting to a database:

# in the root command
...
options => [
   { getopt => 'http_url=s',  transmit => 1 },
   { getopt => 'http_user=s', transmit => 1 },
   { getopt => 'http_pass=s', transmit => 1 },

   { getopt => 'db_url=s',  transmit => 1 },
   { getopt => 'db_user=s', transmit => 1 },
   { getopt => 'db_pass=s', transmit => 1 },
]

You might then want to provide sub-commands that focus on the HTTP or the database portions only, like e.g. a sub-command to check whether connectivity is available. In this case, it would be great to just inherit the relevant options from the parent, instead of all of them, in order to avoid cluttering the help/usage commands with options that make no sense:

# children of the root command
children => [
   {
      aliases => [ qw< check-db-connectivity > ],
      options => [ '(?mxs: \A db_ )' ]
      ...
   },
   {
      aliases => [ qw< check-http-connectivity > ],
      options => [ '(?mxs: \A http_ )' ]
      ...
   },
   ...

Pass 3: commit options along the way

Sometimes it can be hard to pre-determine a default value for an option because its value might depend on multiple other values.

In a single command this is rarely a problem, because the specific computation for the default value might be done at the beginning of the execute callback.

What if the value must be set in the root or an intermediate command instead? As we saw, execute is only called for the leaf command, not for other ones along the way. This is where commit comes handy, together with method inject_configs.

Let's take an example program that supports an option seed and a sub-command to print it (which also inherits the option):

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'seed|randmo-seed=s',
         help   => 'a random seed for no reason!',
         transmit => 1,
      }
   ],
   commit => sub ($self) {
      return if defined($self->config('seed'));
      my $seed = join '-',
         grep { defined }
         @ENV{qw< THIS THAT AND_ALSO_THAT >};
      $seed = rand(1234) unless length($seed // '');
      $self->inject_configs({ seed => $seed });
      warn "WARNING: parent set seed<$seed>\n";
      return;
   },
   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            my $seed = $self->config('seed') // '**undef**';
            say "seed is $seed";
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

The new key commit in the parent command sets a callback that is called immediately after the options gathering process for the specific level (in this case, the parent command).

If option seed is not set, a custom logic assembles it or falls back to a random number. This is just a toy example to represent a custom logic that is difficult to express as a single default value directly inside the option's definition.

Example runs (assuming the program is called seeder):

# parents sets the custom default, which is used in the child
$ seeder seeker
WARNING: parent set seed<1024.55957658367>
seed is 1024.55957658367

# set option in the parent, no custom default is set (no WARNING line)
$ seeder --seed abc seeker
seed is abc

# parent sets custom default, but option is set in the child too and
# the parent's default is ignored
$ seeder seeker --seed def
WARNING: parent set seed<909.487976275958>
seed is def

Pass 4: final_commit

The commit mechanism is useful for setting values along the way, but sometimes we might need to perform some common actions just before a command is executed (at whatever level).

As an example, suppose that your program uses a custom logger like Log::Log4perl::Tiny and that you want to provide a command-line option to set the log level. We might leverage commit to set the log level after options have been collected, like in the following example:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use Log::Log4perl::Tiny qw< :easy LOGLEVEL >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'loglevel|l=s',
         help   => 'logging level for Log::Log4perl::Tiny',
         default => 'info',
      },
   ],
   commit => sub ($self) {
      LOGLEVEL(uc($self->config('loglevel')));
      return;
   },
   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            WARN 'this is WARN';
            INFO 'this is INFO';
            DEBUG 'this is DEBUG';
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

The first example runs seem promising:

$ logger-example seeker
[2024/09/07 16:34:49] [ WARN] this is WARN
[2024/09/07 16:34:49] [ INFO] this is INFO

$ logger-example --loglevel debug seeker
[2024/09/07 16:36:15] [ WARN] this is WARN
[2024/09/07 16:36:15] [ INFO] this is INFO
[2024/09/07 16:36:15] [DEBUG] this is DEBUG

$ logger-example --loglevel warn seeker
[2024/09/07 16:36:20] [ WARN] this is WARN

This program, though, suffers from the problem we addressed earlier in Pass 2, i.e. we can only set the option in the parent command.

Well, we might set transmit and then inherit it, but it would not work:

# WARNING: THIS DOES NOT WORK AS INTENDED (YET)

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use Log::Log4perl::Tiny qw< :easy LOGLEVEL >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'loglevel|l=s',
         help   => 'logging level for Log::Log4perl::Tiny',
         default => 'info',

         # TRANSMIT OPTION, BUT IT WILL NOT WORK OUT OF THE BOX!
         transmit => 1,
      },
   ],
   commit => sub ($self) {
      LOGLEVEL(uc($self->config('loglevel')));
      return;
   },
   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            WARN 'this is WARN';
            INFO 'this is INFO';
            DEBUG 'this is DEBUG';
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

Example run after this change:

$ logger-example seeker --loglevel warn
[2024/09/07 16:34:49] [ WARN] this is WARN
[2024/09/07 16:34:49] [ INFO] this is INFO

What's happening?

The commit callback set in the parent is run immediately after options collections is completed in the parent. At this stage, the program has not seen the option's value in the child yet. As a result, it uses whatever it has at that stage, i.e. the default info value.

To address this specific issue, final_commit comes to the rescue. In the default arrangement, all final_commit callbacks are called in reverse order from the chosen leaf command up to the command root, immediately after the leaf command has completed collecting options. Let's try it out:

# THIS PROGRAM DOES NOT WORK AS INTENDED TOO (YET)

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use Log::Log4perl::Tiny qw< :easy LOGLEVEL >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'loglevel|l=s',
         help   => 'logging level for Log::Log4perl::Tiny',
         default => 'info',
         transmit => 1,
      },
   ],

   ##################################################################
   # WE USE final_commit TO SET THE LOGLEVEL
   final_commit => sub ($self) {
      LOGLEVEL(uc($self->config('loglevel')));
      return;
   },

   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            WARN 'this is WARN';
            INFO 'this is INFO';
            DEBUG 'this is DEBUG';
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

Alas, this does not work yet! Example run after this change:

$ logger-example seeker --loglevel warn
[2024/09/07 16:34:49] [ WARN] this is WARN
[2024/09/07 16:34:49] [ INFO] this is INFO

What is happening now?

Each command level in App::Easer is tracked as an object instance by itself, representing the specific root/intermediate/leaf command. For this reason, methods called on the specific object provide a view from that object's perspective.

The final_commit is set inside the root command, so when we call the config method it only looks at the options collected in the root command. Which means... no loglevel value set in the selected leaf command.

For this reason, method leaf allows getting the final leaf command object that resulted from the search done by App::Easer. Calling it is meaningful only inside final_commit, but it's exactly where we need it:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use Log::Log4perl::Tiny qw< :easy LOGLEVEL >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'loglevel|l=s',
         help   => 'logging level for Log::Log4perl::Tiny',
         default => 'info',
         transmit => 1,
      },
   ],
   final_commit => sub ($self) {

      ###############################################################
      # We get the config from $self->leaf insted of $self
      LOGLEVEL(uc($self->leaf->config('loglevel')));

      return;
   },
   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            WARN 'this is WARN';
            INFO 'this is INFO';
            DEBUG 'this is DEBUG';
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

We're there at last:

$ logger-example seeker --loglevel warn
[2024/09/07 16:34:49] [ WARN] this is WARN

$ logger-example seeker --loglevel debug
[2024/09/07 16:36:15] [ WARN] this is WARN
[2024/09/07 16:36:15] [ INFO] this is INFO
[2024/09/07 16:36:15] [DEBUG] this is DEBUG

Pass 5: Custom source

As anticipated, sometimes we might want to load additional configuration options from a custom source. Let's see how to do it.

Setting a custom callback

Up to now, we used the stock configuration for sources that come with version 2.008, but we can set our own. Consider the following starting example (it will need some tweaking as we will see):

# THIS WORKS BUT CAN BE ENHANCED!

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use Mojo::UserAgent;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'foo=s',
         help   => 'an example command-line option',
         transmit => 1,
      },
      {
         getopt => 'bar=s',
         help   => 'another example command-line option, with env',
         environment => 'BAR',
         transmit => 1,
      },
      {
         help        => 'URL for additional configurations',
         environment => 'CONFIG_URL',
         default     => 'https://dummyjson.com/c/12ec-1af6-4270-8dc3',
      },
   ],
   sources => {
      current => [ qw< +CmdLine +Environment +Default +ParentSlices >, \&get_from_url ],
      #next    => [ qw< +CmdLine +Environment +Default +ParentSlices >],
      final   => [],
   },
   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            say App::Easer::V2::dd(config => $self->config_hash);
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

sub get_from_url ($cmd, $opts, $args) {
   my $url = $cmd->config('config_url');
   warn "getting stuff from $url...\n";
   my $ua = Mojo::UserAgent->new;
   return $ua->get($url)->result->json;
}

This example command loads additional configuration from a URL that serves a JSON file (I hope the default value continues to work for some time, but you get the idea anyway). Option config_url holds this URL, and it can only be set from the environment variable CONFIG_URL (which, by the way, also gives the option its name as a lowercase representation) or from the default value.

Key sources is set like this:

sources => {
   current => [ qw< +CmdLine +Environment +Default +ParentSlices >,
      \&get_from_url ],
},

The string sources are the default ones: command-line, then environment, then defaults, then parent. The usual stuff.

The final source, though, is a reference to a sub that, when called, provides back the desired configuration, getting it dynamically from the URL:

sub get_from_url ($cmd, $opts, $args) {
   my $url = $cmd->config('config_url');
   warn "getting stuff from $url...\n";
   my $ua = Mojo::UserAgent->new;
   return $ua->get($url)->result->json;
}

This custom source must adhere to the above signature, i.e. receiving:

  • a reference to the command object (it's the one invoking the source);

  • a reference to an array of options for the source. In this case we just provided the source as a bare sub reference, so this is an empty array;

  • a reference to the array of command-line arguments that have not been processed so far.

Let's run the sub-command:

$ remote-config-example --bar whatever seeker --foo BARBARBAR
getting stuff from https://dummyjson.com/c/12ec-1af6-4270-8dc3...
getting stuff from https://dummyjson.com/c/12ec-1af6-4270-8dc3...
$VAR1 = {
  'config' => {
     'bar' => 'whatever',
     'baz' => 'galook',
     'config_url' => 'https://dummyjson.com/c/12ec-1af6-4270-8dc3',
     'foo' => 'BARBARBAR'
  }
};

It's working, although a bit too much. As you can see, the source callback is invoked twice, i.e. once in the parent and once in the child command. Most probably this is not what we want.

Why is that? By default, children get the same sources as the parent (unless, of course, specific sources are set in the child itself). This means that our custom source is set in both commands and it is invoked twice.

To cope with this problem, we might work this around in the callback, avoiding the double download if we detect that we're not in the root command:

sub get_from_url ($cmd, $opts, $args) {

   #################################################################
   # don't do anything if we're not the root command!
   return unless $cmd->is_root;

   my $url = $cmd->config('config_url');
   warn "getting stuff from $url...\n";
   my $ua = Mojo::UserAgent->new;
   return $ua->get($url)->result->json;
}

There's a cleaner way, as we will see shortly.

Setting sources for children

To cope with these situation in which one is enough, the 2.008 hash-based interface for sources supports setting a next key too, providing the sources to be set for each child that has not its own yet. Let's see how to set it:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use Mojo::UserAgent;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'foo=s',
         help   => 'an example command-line option',
         transmit => 1,
      },
      {
         getopt => 'bar=s',
         help   => 'another example command-line option, with env',
         environment => 'BAR',
         transmit => 1,
      },
      {
         help        => 'URL for additional configurations',
         environment => 'CONFIG_URL',
         default     => 'https://dummyjson.com/c/12ec-1af6-4270-8dc3',
      },
   ],
   sources => {
      current => [ qw< +CmdLine +Environment +Default +ParentSlices >,
         \&get_from_url ],

      #################################################################
      # (default) sources for the children
      next => [ qw< +CmdLine +Environment +Default +ParentSlices >],

   },
   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            say App::Easer::V2::dd(config => $self->config_hash);
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

sub get_from_url ($cmd, $opts, $args) {
   my $url = $cmd->config('config_url');
   warn "getting stuff from $url...\n";
   my $ua = Mojo::UserAgent->new;
   return $ua->get($url)->result->json;
}

It usage is easy: whatever is put, it's also used as the default list of sources in the children. In this way we can get rid of the custom source as soon as we exit from the parent command:

# Now "getting stuff from https://..." is invoked only once
$ remote-config-example --bar whatever seeker --foo BARBARBAR
getting stuff from https://dummyjson.com/c/12ec-1af6-4270-8dc3...
$VAR1 = {
  'config' => {
     'bar' => 'whatever',
     'baz' => 'galook',
     'config_url' => 'https://dummyjson.com/c/12ec-1af6-4270-8dc3',
     'foo' => 'BARBARBAR'
  }
};

Pass 6: final in sources

As we saw before in Pass 4, there are times where we need to go down the line up to the collection of all options in order to figure out possible additional actions (in that case, it was setting the right logging level).

What if we need this for custom sources too? In the previous section example, we astutely get our configuration URL from the environment, but what if we want to support it as a command-line option and moreover we want also to propagate (via inheritance) that option in children?

In this case, our previous setup would not work. Whatever we set as current in the root command will be run when the root command is analyzed, which happens before the child command.

There are a couple of solutions here. One is to leverage final_commit as in Pass 4, i.e. moving the code for dynamic loading of the URL inside final_commit and use inject_config (like in Pass 3) to add the newly downloaded configurations.

There is a cleaner approach, though, which consists in using key final inside the sources hash, like in the following example:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;
use Mojo::UserAgent;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   help    => 'An example',
   config_hash_key => 'v2.008',
   options => [
      {
         getopt => 'foo=s',
         help   => 'an example command-line option',
         transmit => 1,
      },
      {
         getopt => 'bar=s',
         help   => 'another example command-line option, with env',
         environment => 'BAR',
         transmit => 1,
      },
      {
         ###############################################################
         # Option is promoted to the command-line and made available to
         # children too
         getopt      => 'config_url=s',
         transmit    => 1,
         # try https://dummyjson.com/c/b318-383e-43df-acd6 from cmd line

         help        => 'URL for additional configurations',
         environment => 'CONFIG_URL',
         default     => 'https://dummyjson.com/c/12ec-1af6-4270-8dc3',
      },
   ],
   sources => {

      ##################################################################
      # current is restored to its original, default setting for v2.008
      current => [ qw< +CmdLine +Environment +Default +ParentSlices > ],

      ##################################################################
      # the custom source is moved into final
      final   => [ \&get_from_url ],
   },
   children => [
      {
         aliases => [qw< seeker >],
         help    => 'some add-on to look at the seed!',
         description => '',
         options => [ '+parent' ],
         execute => sub ($self) {
            say App::Easer::V2::dd(config => $self->config_hash);
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

sub get_from_url ($cmd, $opts, $args) {
   my $url = $cmd->config('config_url');
   warn "getting stuff from $url...\n";
   my $ua = Mojo::UserAgent->new;
   return $ua->get($url)->result->json;
}

Sample calls;

# set config_url in the root command
$ remote-example \
   --config_url https://dummyjson.com/c/b318-383e-43df-acd6 --bar whatever \
   seeker --foo BARBARBAR
getting stuff from https://dummyjson.com/c/b318-383e-43df-acd6...
$VAR1 = {
  'config' => {
    'bar' => 'whatever',
    'baz' => 'Galook for the win!',
    'config_url' => 'https://dummyjson.com/c/b318-383e-43df-acd6',
    'foo' => 'BARBARBAR'
  }
};

# ditto, remove "--bar" from command line. Only the "new" remote JSON
# config has key "bar" set to the displayed value, so we know it's it
$ remote-example --config_url https://dummyjson.com/c/b318-383e-43df-acd6 \
   seeker --foo BARBARBAR
getting stuff from https://dummyjson.com/c/b318-383e-43df-acd6...
$VAR1 = {
  'config' => {
    'bar' => 'whateeeeever!',
    'baz' => 'Galook for the win!',
    'config_url' => 'https://dummyjson.com/c/b318-383e-43df-acd6',
    'foo' => 'BARBARBAR'
  }
};

# set config_url in the child command. Again, that value for "bar" comes
# from the JSON provided on the command line
$ remote-example \
   seeker \
      --foo BARBARBAR \
      --config_url https://dummyjson.com/c/b318-383e-43df-acd6
getting stuff from https://dummyjson.com/c/b318-383e-43df-acd6...
$VAR1 = {
  'config' => {
    'bar' => 'whateeeeever!',
    'baz' => 'Galook for the win!',
    'config_url' => 'https://dummyjson.com/c/b318-383e-43df-acd6',
    'foo' => 'BARBARBAR'
  }
};

Pass 7: Getting intermediates to work

So far in these tutorial passes we assumed that root/intermediate commands are only a way to structure our tree, while the real execution is performed by the leaves of our commands tree. This is basically why you get the help/usage/commands/tree sub-commands out of the box for gree for all non-leaf commands, as well as a default to the usage sub-command in case no sub-command is provided on the command line.

Well, you might beg to differ.

The first step is, of course, defining an execute callback for actually doing something when we determine that the non-leaf command should be run. And yet this is not enough, as the default is to call usage in case no sub-command can be found.

Setting a default_child different fro usage

It turns out this behaviour is not hardcoded, but the effect of the default on the default_child key in the applications' definition. As an example:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   children => [
      {
         aliases => [qw< foo >],
         execute => sub ($self) {
            say 'foo here!';
            return 0;
         },
      },
      {
         aliases => [qw< bar >],
         execute => sub ($self) {
            say 'bar here!';
            return 0;
         },
      },
   ],

   #####################################################################
   # this sets what's done *by* the root command
   execute => sub ($self) {
      say 'MAIN (root) here!';
      return 0;
   },

   #####################################################################
   # this makes the command itself the default command to call when
   # nothing more is provided on the command line. The default value
   # is 'usage'.
   default_child => '-self',

};

exit(run($app, $0, @ARGV) // 0);

Sample calls:

$ root-exec foo
foo here!

$ root-exec bar
bar here!

$ root-exec
MAIN (root) here!

You might also want to set fallback_to...

While the example in the previous section works, it's still a bit fragile, because it makes the upper command able to run with regular options but not with non-option command-line arguments (i.e. those that end up populating residual_args):

$ root-exec galook
cannot find sub-command 'galook'

This happens because App::Easer defaults to looking for a child command and complains under the assumption that the user might have mistyped a sub-command's name.

Again, this is not hardcoded but the effect of a configuration option, namely fallback_to. By setting it to -self you can ask for using the command itself as the fallback in case no sub-command can be found:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   children => [
      {
         aliases => [qw< foo >],
         execute => sub ($self) {
            say 'foo here!';
            return 0;
         },
      },
      {
         aliases => [qw< bar >],
         execute => sub ($self) {
            say 'bar here!';
            return 0;
         },
      },
   ],
   execute => sub ($self) {
      my @args = $self->residual_args;
      say "MAIN (root) here! Also got (@args)";
      return 0;
   },
   default_child => '-self',

   #####################################################################
   # this sets the MAIN command as the default command to run if no
   # child is found when additional residual-args are provided on the
   # command line
   fallback_to   => '-self',
};

exit(run($app, $0, @ARGV) // 0);

This works now:

$ root-exec-with-fallback galook burp
MAIN (root) here! Also got (galook burp)

It's also possible to set fallback_to to string -default to just replicate whatever is set for default_child (should you ever change your mind and want the two mirror each other).

If you need more flexibility, take a look at key fallback in the main documentation.

Why was this executed?

App::Easer does its best to figure out which command/sub-command should be executed. As we saw in the previous sub-sections, it might have different reasons for running a specific command, be it because it's the default or a fallback. If you need it, you can call execution_reason to figure out why, receiving back a string among -leaf, -default, or -fallback.

Pass 8: aliases and call_name

Each command in App::Easer supports a name to set the command's name. Many times, though, it's useful to also support aliases for a command, e.g. if you want your users to call sub-command list with a shorter version ls.

Key aliases in the command's specification allows setting these aliases. As a matter of fact, you might just set it and forget about name, which will be set to the first alias in case. You decide what's best for you:

my $app1 = {
   name => 'foo',
   aliases => [qw< bar baz >],
   ...
};

my $same_as_app1 = {
   aliases => [qw< foo bar baz >],
   ...
};

Sometimes you might want to provide different behaviours for different aliases, but the underlying implementation is basically the same (including e.g. command-line options) and you don't want to fire up two different sub-commands. In this case, you can call method call_name on the command provided in the callback to figure out how the sub-command was actually invoked.

Example:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   children => [
      {
         aliases => [qw< foo bar >],
         execute => sub ($self) {
            my $name = $self->call_name;
            say "$name here!";
            return 0;
         },
      },
   ],
};

exit(run($app, $0, @ARGV) // 0);

Sample calls:

$ check-name foo
foo here!

$ check-name bar
bar here!

# let's double check that it does not syphon up everything!
$ check-name baz
cannot find sub-command 'baz'

The call_name method also works at the top level, allowing to create top-level (root) commands that have their own behaviour (see previous Pass) as well as the possibility to change it depending on how they were called. The only caveat in this case is that you will get the full path to the executable (or a link to it), so you might want to account for it:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   children => [
      {
         aliases => [qw< foo bar >],
         execute => sub ($self) {
            my $name = $self->call_name;
            say "$name here!";
            return 0;
         },
      },
   ],
   execute => sub ($self) {
      my $path = $self->call_name;
      my $name = $path =~ s{\A.*/}{}rmxs;
      my @args = $self->residual_args;
      say "$name (root) here! Also got (@args)";
      return 0;
   },
   default_child => '-self',
   fallback_to   => '-self',
};

exit(run($app, $0, @ARGV) // 0);

Let's see it in action:

# let's first create some aliases for our toy example
$ ln -s example-command main-x 
$ ln -s example-command main-y

$ main-x galook
main-x (root) here! Also got (galook)

$ main-y burp
main-y (root) here! Also got (burp)

Pass 9: More about help

App::Easer comes with a pre-defined help system that makes it possible to set a short and a long help description, while leaving to App:Easer the burden to document all options and possibly expose available sub-commands. There are some defaults and assumptions that you might want to change, though.

Invoking the help

The basic assumption in App::Easer is that intermediate commands (i.e. commands with children) also get three more sub-commands for free, namely usage, help, commands, and tree. I'd argue that usage and help are the most interesting of the lot.

This means that your interface might expose a slight inconsistency in how help is obtained. Assuming our application has the main entry point with child foo, which in turn has a child bar, we would get the following out of the box:

$ main-executable
# prints out the usage for main-executable

$ main-executable help
# prints out the help for main-executable, i.e. the same as usage but
# with the Description section included

$ main-executable help foo
# prints out the help for sub-command foo

$ main-executable foo help
# same as above

$ main-executable foo help bar
# prints out the help for sub-sub-command bar

You see? There is no main-executable foo bar help like we have for upper-level commands, because we're assuming that leaf commands should just do their job.

At this point, we might just accept this and move on. There are probably better things to do. Otherwise, read on.

Assuming that the literal string help cannot possibly be a non-command-line option argument, it's still possible to inject this behaviour easily in the execute callback of the leaf command, like this:

# this is the execute in sub-sub-command bar
execute => sub ($self) {
   my @args = $self->residual_args;

   # look for standalone 'help' or 'usage' in residual arguments
   return $self->run_help if @args == 1 && ($args[0] // '') eq 'help';
   return $self->run_help('usage')
      if @args == 1 && ($args[0] // '') eq 'usage';
   ...
},

On the other hand, if you need to accept any string as residual arguments for your program/sub-command, you might instead opt for supporting options like --help/--usage and use the same trick as above:

# this happens in sub-sub-command bar
options => [
   { getopt => 'help' },
   { getopt => 'usage' },
   ...
],
execute => sub ($self) {
   return $self->run_help          if $self->config('help');
   return $self->run_help('usage') if $self->config('usage');
   ...
},

At this point, you might introduce these two options in the root command, set their transmit to a true value, and inherit it at every level below. This gives you options --help/--usage consistently at every level, at the cost of some code repetition at the beginning of each execution.

If you're interested in help, there's more. You can get the help text by calling method full_help_text (optionally passing usage to get the shorter version, i.e. without the Description section).

Documenting options

App::Easer does its best to generate documentation for options, based on their definition.

You might want to tweak things, though, and this is where key options_help comes to the rescue.

If set to a string, it's used as the entire text for the options' help, no questions asked.

If you find this a bit too extreme, you can set it to a hash reference supporting two (optional) keys preamble and postamble, each pointing to a string value. In this case, the options' help is still generated automatically as in the default case, but the text in the preamble is pre-pended and the text in the postable is appended to this auto-generated string. This might e.g. come handy in case you also want to add documentation related to non-option command-line arguments, e.g. to indicate that they are files, urls, whatever.

Pass 10: Shared Behaviour

As your application grows, you will almost inevitably face the dilemma of where to put and how to handle shared behaviour, i.e. all those activities that are common to most if not all sub-commands.

It might be anything, like using a specific logging library, coping with the need to access a shared model object, etc.

There are a few strategies that you can adopt, discussed below.

Leverage the root command

Each App::Easer application has one single root command and you can be sure that there will always be an instance of that command's class.

One strategy for storing common behaviour, then, would be to put it in the root command implementation and then use method root to retrieve the command's object instance and consume the behaviour from there.

The downside of this approach is that you need to implement your main command as a class instead of a hash definition like every example seen so far. So while definitely possible, you might not know (yet) how to do it.

Use a common base via hashy_class

Although every command/sub-command definition seen in the example so far has been provided as a simple hash reference full of keys and callbacks, we've already seen that each command in a command chain is eventually instantiated as a class object.

Each of these objects are normally instances of class App::Easer::V2::Command, but they need not be. By setting key hashy_class in the command's definition to a different class... that's what will be used eventually.

This means that you might define a class derived from App::Easer::V2::Command and add the shared behaviour there; each command will inherit it. This includes the root command too, of course.

Use the configuration

One of the main features of App::Easer is about managing configuration options; they can be taken from the outside or generated dynamically and recorded in multiple ways (think about commit and final_commit for example).

So one alternative is to encapsulate common behaviours in one or more object instances, then save them as configuration options that are set in the leaf configuration using method leaf to retrieve it (e.g. from the root command) and method set_config to set the object.

Use shared state

If your application is small(ish) and defined in a single hash as all eexamples so far, it's still possible to use global variables or lexical variables accessible from all callbacks, like this:

my $shared_object = My::Class->new(...);
my $app = {
   ...
   execute => sub ($self) {
      $shared_object->do_this;
      ...
};

Pass 11: Class-based commands

If your application is composed of a few commands, managing it through a single hash definition is handy and allows you to keep an overall control.

As the application grows, your definition hash grows too and it can become too big for easy management. At this point, you might want to consider splitting the whole application and manage each (sub-)command on its own.

App::Easer allows transferring application features from the hash-based declaration to class methods, while not requiring a full transfer. Let's start from the fictional application in Pass 7, containing one root-level command and two sub-commands:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;

use App::Easer::V2 qw< run >;

my $app = {
   aliases => [qw< MAIN >],
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   children => [
      {
         aliases => [qw< foo >],
         execute => sub ($self) {
            say 'foo here!';
            return 0;
         },
      },
      {
         aliases => [qw< bar >],
         execute => sub ($self) {
            say 'bar here!';
            return 0;
         },
      },
   ],
   execute => sub ($self) {
      my @args = $self->residual_args;
      say "MAIN (root) here! Also got (@args)";
      return 0;
   },
   default_child => '-self',

   #####################################################################
   # this sets the MAIN command as the default command to run if no
   # child is found when additional residual-args are provided on the
   # command line
   fallback_to   => '-self',
};

exit(run($app, $0, @ARGV) // 0);

The equivalent class-based implementation is based on 4 different parts, i.e. an entry point program and three classes. They can still be kept inside the same file, although you might want to split them into each own file:

#!/usr/bin/env perl
use v5.24;
use warnings;
use English;
use experimental qw< signatures >;

exit(MyApp->new->run($0, @ARGV) // 0);

# class for the root command
package MyApp;
use App::Easer::V2 -command => -spec => {
   aliases => [qw< MAIN >],
   sources => 'v2.008',
   config_hash_key => 'v2.008',
   default_child => '-self',
   fallback_to   => '-self',
};

sub execute ($self) {
   my @args = $self->residual_args;
   say "MAIN (root) here! Also got (@args)";
   return 0;
};


package MyApp::CmdFoo;
use App::Easer::V2 -command => -spec => {
   aliases => [qw< foo >],
};

sub execute ($self) {
   say 'foo here!';
   return 0;
};


package MyApp::CmdBar;
use App::Easer::V2 -command => -spec => {
   aliases => [qw< bar >],
};

sub execute ($self) {
   say 'bar here!';
   return 0;
};

This setup allows a seamless transition of features from the hash-based approach to the method-based one. As long as your application traits are plain data (like aliases, help, etc.) it's possible to treat them as such and keep them inside the hash provided as argument for use; everything different can be treated through a method.

As an example, you might want to keep help as POD in the file, and use some POD-handling code to get it. In this case you might want to move either help or description (or both) into their own methods.

Sub-command classes must adhere to a naming convention; by default, their last-part name must start with string Cmd but this can be set via key children_prefixes (see the main reference documentation for the details). This allows having command and non-command classes inside the same directory (i.e. at the same package level) without intereference.

AUTHOR

Flavio Poletti <flavio@polettix.it>

COPYRIGHT AND LICENSE

Copyright 2024 by Flavio Poletti <flavio@polettix.it>

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.