Getopt...torial(3pm)

πŸ“¦ version 2.000011, in ubuntu stonking

πŸ¦‹ Getopt...torial(3pm) πŸ¦‹

okay bestie, so this is Getopt::Yath::Tutorial β€” out of User Contributed Perl Documentation, living in the 3pm section, and yes the page title is literally truncated to Getopt...torial(3pm) like someone hit backspace at the worst possible moment 😭 we move.


✨ NAME ✨

Getopt::Yath::Tutorial β€” A step-by-step guide to using Getopt::Yath. periodt.


πŸ’– DESCRIPTION πŸ’–

so this tutorial is literally holding your hand through using Getopt::Yath to handle command-line options in your Perl scripts and your modules. it starts cute and simple and then builds all the way up to the advanced stuff. we're going on a journey bestie, grab a snack πŸ“


πŸŽ€ YOUR FIRST SCRIPT πŸŽ€

here's the simplest useful script you can write with Getopt::Yath. look at her, she's iconic:

#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Yath;

option_group {group => 'my', category => 'My Options'} => sub {

    option name => (
        type        => 'Scalar',
        description => 'Your name',
    );

};

my $state = parse_options(\@ARGV);
my $settings = $state->settings;

print "Hello, " . ($settings->my->name // 'World') . "!\n";

now run it, no cap:

$ perl hello.pl --name Bob
Hello, Bob!

$ perl hello.pl
Hello, World!

What just happened? πŸ‘€

okay so use Getopt::Yath exports a whole lil squad of functions straight into your package:

  • option β€” defines a single command-line option, with a name and a spec. one option, one baddie.
  • option_group β€” sets shared attributes (group, category, prefix, etc.) for a whole block of options. it's giving "inheritance but make it cute".
  • parse_options β€” takes an arrayref of command-line arguments and hands you back a state hashref with settings, the remaining args, skipped items, and more.
  • include_options β€” imports option definitions from another module that also uses Getopt::Yath. sharing is caring πŸ’–
  • option_post_process β€” registers a callback that runs after every option has been parsed, so you can do cross-option validation or lil fixups.
  • options β€” returns the Getopt::Yath::Instance object that holds all your defined options. this is the one you use for generating help and documentation output.
  • category_sort_map β€” controls the display order of option categories in help output. aesthetics matter.
every single option needs a group (that's the key it shows up under in settings) and a type (that's how it parses arguments). the category is just a human-readable label for help output. three things, don't forget 'em.

🌈 OPTION GROUPS 🌈

option_group lets you set shared attributes for a whole block of options so you are not out here repeating yourself like a broken record. the way that DRY principle ate πŸ’…

option_group {group => 'server', category => 'Server Options'} => sub {

    option host => (
        type        => 'Scalar',
        default     => 'localhost',
        description => 'Hostname to bind to',
    );

    option port => (
        type        => 'Scalar',
        short       => 'p',
        default     => 8080,
        description => 'Port to listen on',
    );

    option verbose => (
        type        => 'Bool',
        short       => 'v',
        description => 'Enable verbose output',
    );

};

my $state = parse_options(\@ARGV);
my $settings = $state->settings;

printf "Starting server on %s:%s\n",
    $settings->server->host,
    $settings->server->port;

every option declared inside that block inherits group => 'server' and category => 'Server Options'. and you grab the parsed values with $settings->server->host, $settings->server->port, etc. easy.


πŸ”₯ OPTION TYPES πŸ”₯

Getopt::Yath ships a whole rich set of option types. here they are, going from simplest to most specialized. buckle up 🧚

Bool

a simple on/off flag. no argument needed, she doesn't need anything from you.

option verbose => (
    type        => 'Bool',
    short       => 'v',
    default     => 0,
    description => 'Enable verbose output',
);

usage:

--verbose       # turns it on (1)
-v              # same thing
--no-verbose    # turns it off (0)

Scalar

takes exactly one value. one. not two. one.

option output => (
    type        => 'Scalar',
    short       => 'o',
    description => 'Output file path',
);

usage:

--output results.txt
--output=results.txt
-o results.txt
-o=results.txt
-oresults.txt       # short form allows value directly after flag
--no-output         # clears the value (sets to undef)

Count

an incrementing counter. every use bumps the value by one. yes, it's the -vvv girlie.

option verbosity => (
    type        => 'Count',
    short       => 'v',
    initialize  => 0,
    description => 'Increase verbosity level',
);

usage:

-v          # 1
-vvv        # 3 (short flags can be stacked)
--verbosity # 1
-v -v -v    # 3
-v=5        # explicitly set to 5
--no-verbosity  # reset to 0

List

collects multiple values into an arrayref. you can specify it multiple times, she's generous like that.

option include => (
    type        => 'List',
    short       => 'I',
    description => 'Add a directory to the include path',
);

usage:

-I lib -I blib/lib
--include lib --include blib/lib
--no-include    # empties the list

Lists also accept JSON arrays:

--include '["lib","blib/lib"]'

Splitting values

use split_on to allow comma-separated (or whatever other delimiter you vibe with) values:

option tags => (
    type        => 'List',
    split_on    => ',',
    description => 'Comma-separated list of tags',
);

# --tags foo,bar,baz   =>   ['foo', 'bar', 'baz']

Map

collects key=value pairs into a hashref. can be specified multiple times.

option env_var => (
    type        => 'Map',
    short       => 'E',
    alt         => ['env-var'],
    description => 'Set an environment variable',
);

usage:

-E HOME=/tmp -E USER=test
--env-var HOME=/tmp
--env-var '{"HOME":"/tmp","USER":"test"}'   # JSON also works
--no-env-var    # empties the hash

like List, Map supports split_on for multiple pairs in one argument, plus key_on to change the key/value delimiter (default is =).

Auto

a scalar option with an autofill value. --opt uses the autofill value, --opt=val uses the value you gave it. it does not support --opt val (the space-separated form), because with no required argument the next token is ambiguous. don't try it bestie, it's giving chaos.

option coverage => (
    type        => 'Auto',
    autofill    => '-silent,1',
    description => 'Enable coverage; optionally provide Devel::Cover args',
    long_examples  => ['', '=-silent,1'],
);

usage:

--coverage          # uses autofill: "-silent,1"
--coverage=-db,cov  # uses the provided value
--no-coverage       # clears

AutoList

combines Auto and List. --opt adds the autofill values to the list, --opt=val adds a specific value.

option plugins => (
    type        => 'AutoList',
    autofill    => sub { qw/Foo Bar/ },
    description => 'Plugins to load (defaults to Foo and Bar)',
);

usage:

--plugins           # adds 'Foo' and 'Bar'
--plugins=Baz       # adds 'Baz'
--no-plugins        # empties the list

AutoMap

combines Auto and Map. --opt adds the autofill key/value pairs, --opt=key=val adds a specific pair.

option defaults => (
    type        => 'AutoMap',
    autofill    => sub { timeout => 30, retries => 3 },
    description => 'Default settings',
);

usage:

--defaults              # adds {timeout => 30, retries => 3}
--defaults=color=red    # adds {color => 'red'}
--no-defaults           # empties the hash

PathList

like List, but values containing wildcards get expanded using glob(). glow up ✨

option test_files => (
    type        => 'PathList',
    split_on    => ',',
    description => 'Test files to run (globs allowed)',
);

usage:

--test-files 't/*.t'
--test-files t/foo.t,t/bar.t
--test-files 'lib/**/*.pm'

AutoPathList

combines AutoList and PathList. --opt adds autofill paths, --opt=glob expands the glob.

option dev_libs => (
    type        => 'AutoPathList',
    short       => 'D',
    name        => 'dev-lib',
    autofill    => sub { 'lib', 'blib/lib', 'blib/arch' },
    description => 'Add dev library paths (default: lib, blib/lib, blib/arch)',
);

usage:

-D              # adds lib, blib/lib, blib/arch
-D=lib          # adds just lib
-D='lib/*'      # adds all matches

BoolMap

matches a pattern of options dynamically, building a hash of boolean values. requires a pattern attribute with a capture group. very dynamic, very main character.

option features => (
    type        => 'BoolMap',
    pattern     => qr/feature-(.+)/,
    description => 'Toggle features on or off',
);

usage:

--feature-color         # {color => 1}
--feature-unicode       # {unicode => 1}
--no-feature-color      # {color => 0}

the pattern gets embedded into qr/^--(no-)?$pattern$/ automatically. each captured key gets a true or false value depending on the --no- prefix. iconic behaviour honestly.


okay that's part 1 of this saga πŸ‘‘ more parts coming, don't go anywhere bestie πŸ’œ

✨ COMMON OPTION ATTRIBUTES ✨

Okay bestie, these attributes are the universal ones β€” they work across all option types. Like, every single one. Slay. πŸ’…

short

A single-character alias for the option, because sometimes you just don't have the energy to type the whole thing:

option jobs => (
    type  => 'Scalar',
    short => 'j',
    ...
);

# -j4   --jobs 4   --jobs=4   -j 4

alt and alt_no

Alternate long names, bestie. Extra little nicknames for your option:

option use_stream => (
    type   => 'Bool',
    alt    => ['stream'],       # --stream also works
    alt_no => ['TAP'],          # --TAP is equivalent to --no-use-stream
    description => 'Use streaming format instead of TAP',
);

prefix

Adds a prefix to the option name. Highkey a lifesaver inside option groups:

option_group {group => 'db', category => 'Database', prefix => 'db'} => sub {

    option host => (
        type => 'Scalar',
        description => 'Database host',
    );

    option port => (
        type    => 'Scalar',
        default => 5432,
        description => 'Database port',
    );

};

# --db-host myhost --db-port 3306

The values are still accessed as "$settings->db->host" and "$settings->db->port" β€” the prefix only affects the command-line form. It's giving cosmetic, and honestly? We respect it. πŸŽ€

field and name

By default, the option title decides both the field name (underscores) and the CLI name (dashes). But you can override either one:

option test_args => (
    type  => 'List',
    field => 'args',        # $settings->tests->args (not test_args)
    alt   => ['test-arg'],
    ...
);

default

The value used when the option isn't given on the command line and isn't set via an environment variable:

option timeout => (
    type    => 'Scalar',
    default => 60,
    description => 'Timeout in seconds',
);

# Can also be a coderef:
option search => (
    type    => 'PathList',
    default => sub { './t', './t2' },
    ...
);

initialize

Sets an initial value before parsing even begins. How is this different from "default", you ask? Great question, bestie. "default" gets applied after parsing if the option was never set. "initialize" sets a starting value that command-line arguments can then modify. Subtle, iconic, powerful. πŸ¦‹

option verbosity => (
    type       => 'Count',
    initialize => 0,
    ...
);

🌈 ENVIRONMENT VARIABLES 🌈

from_env_vars

Read an option's initial value straight from environment variables. The first one that's actually defined wins:

option verbose => (
    type          => 'Bool',
    from_env_vars => ['MYAPP_VERBOSE', 'VERBOSE'],
    description   => 'Enable verbose output',
);

Slap a "!" in front of a variable to negate its value:

option quiet => (
    type          => 'Bool',
    from_env_vars => ['!VERBOSE'],     # quiet = true when VERBOSE is false
    description   => 'Suppress output',
);

set_env_vars

Sets environment variables after parsing is complete. Note the vibe check: only Bool, Scalar, Count, and Auto types get this privilege πŸ‘‘

option cover => (
    type          => 'Auto',
    autofill      => '-silent,1',
    from_env_vars => ['T2_DEVEL_COVER'],
    set_env_vars  => ['T2_DEVEL_COVER'],
    description   => 'Enable Devel::Cover',
);

The "!" prefix works here too, for inverted env vars. As it should. πŸ’…

clear_env_vars

Clears specified environment variables after parsing:

option token => (
    type           => 'Scalar',
    from_env_vars  => ['AUTH_TOKEN'],
    clear_env_vars => ['AUTH_TOKEN'],    # don't leak to child processes
    description    => 'Auth token (cleared from env after reading)',
);

πŸ’– NORMALIZE AND TRIGGER πŸ’–

normalize

Transform a value as it's parsed. Glow-up for your strings:

option module => (
    type      => 'Scalar',
    normalize => sub {
        my ($val) = @_;
        $val =~ s/-/::/g;      # Allow Foo-Bar instead of Foo::Bar
        return $val;
    },
    description => 'Module name',
);

# --module Foo-Bar  =>  "Foo::Bar"

For List and Map types, normalize gets called on each value individually. One at a time, no shortcuts. 😌

trigger

A callback that fires whenever the option is set or cleared from the command line. And listen β€” triggers do not fire for defaults, autofill, or initialization. They're not here for that drama:

option input_file => (
    type    => 'Scalar',
    trigger => sub {
        my $opt    = shift;
        my %params = @_;

        return unless $params{action} eq 'set';
        my ($file) = @{$params{val}};
        die "File not found: $file\n" unless -f $file;
    },
    description => 'Input file',
);

The %params hash contains:

action   => 'set' or 'clear'
val      => \@values (arrayref, even for scalars)
ref      => \$field_ref
state    => $parse_state
options  => $instance
settings => $settings
group    => $group

🌸 ALLOWED VALUES 🌸

Restrict what values an option will accept. Boundaries, bestie. We love to see them:

option format => (
    type           => 'Scalar',
    allowed_values => [qw/json csv xml/],
    description    => 'Output format',
);

# --format json   # ok
# --format yaml   # dies: Invalid value

"allowed_values" can be an arrayref, a regex, or a coderef:

allowed_values => qr/^\d+$/,            # must be numeric
allowed_values => sub { $_[1] > 0 },    # must be positive

Use "allowed_values_text" to customise the help message:

allowed_values_text => 'json, csv, or xml',

πŸ‘‘ PARSING OPTIONS πŸ‘‘

The "parse_options" function is the moment β€” this is how you process command-line arguments. It hands back a state hashref with all the parsed data. Everything. The whole vibe. πŸ”₯

Basic parsing

my $state = parse_options(\@ARGV);

my $settings = $state->settings;     # Getopt::Yath::Settings object
my $remains  = $state->remains;      # args after a stop token
my $skipped  = $state->skipped;      # skipped non-options
my $stop     = $state->stop;         # what token stopped parsing
my $env      = $state->env;          # env vars that were/would be set
my $cleared  = $state->cleared;      # options cleared via --no-opt
my $modules  = $state->modules;      # modules whose options were used

Stops

Use "stops" to stop parsing at certain tokens. Most commonly used for "--":

my $state = parse_options(\@ARGV,
    stops => ['--'],
);

# perl script.pl --verbose -- --not-an-option foo bar
#   $state->stop    = '--'
#   $state->remains = ['--not-an-option', 'foo', 'bar']

And you can define multiple stop tokens, because why limit yourself. "::" is commonly used as a separator when you're passing arguments through to tests:

my $state = parse_options(\@ARGV,
    stops => ['--', '::'],
);

# perl script.pl --verbose :: --some-test-arg
#   $state->stop    = '::'
#   $state->remains = ['--some-test-arg']

Handling non-options

By default, hitting a non-option (anything not starting with "-") throws an error. Rude of it, honestly. But you can change this:

# Skip non-options (collect them)
my $state = parse_options(\@ARGV,
    skip_non_opts => 1,
);
my @files = @{$state->skipped};

# Stop at first non-option
my $state = parse_options(\@ARGV,
    stop_at_non_opts => 1,
);
# $state->stop = the non-option that caused the stop
# $state->remains = everything after it

Handling invalid options

Same energy, but for unrecognised options:

# Skip invalid options
my $state = parse_options(\@ARGV,
    skip_invalid_opts => 1,
);

# Stop at invalid options
my $state = parse_options(\@ARGV,
    stop_at_invalid_opts => 1,
);

Suppressing env var side effects

Stop parse_options from touching %ENV at all:

my $state = parse_options(\@ARGV,
    no_set_env => 1,
);

# $state->env still shows what would have been set

Argument groups

Argument groups let you collect arguments between delimiter tokens into an arrayref. So useful for passing structured groups of values:

my $state = parse_options(\@ARGV,
    groups => { ':{' => '}:' },
);

# perl script.pl --opt :{ arg1 arg2 arg3 }:
# The :{ ... }: group is collected as ['arg1', 'arg2', 'arg3']

Groups can be used as option values or as standalone arguments β€” standalone ones end up in "skipped" when "skip_non_opts" is enabled. 🌈


πŸŽ€ GENERATING HELP OUTPUT πŸŽ€

CLI help

sub show_help {
    print options()->docs('cli');
}

This gives you formatted terminal output with ANSI colors (when available), all organised by category. Chef's kiss. 😭

POD documentation

sub show_pod {
    print options()->docs('pod', head => 2);
}

This produces POD markup that's suitable for embedding in your module's documentation. The "head" parameter controls the heading level (like, e.g., "=head2").

Controlling category order

By default categories get sorted alphabetically. Use "category_sort_map" when you want to control the order yourself:

category_sort_map(
    'General Options' => 1,
    'Server Options'  => 2,
    'Debug Options'   => 3,
);

Lower values appear first. Simple as. ⭐

πŸ’– REUSABLE OPTION LIBRARIES πŸ’–

okay so here's the glow-up: you can define your options in a module and then just include them somewhere else. bestie, that's how you build composable option sets, and it's genuinely iconic. 🫢

✨ Defining an option library ✨

package My::Options::Database;
use strict;
use warnings;
use Getopt::Yath;

option_group {group => 'db', category => 'Database Options'} => sub {

    option host => (
        type    => 'Scalar',
        default => 'localhost',
        description => 'Database hostname',
    );

    option port => (
        type    => 'Scalar',
        default => 5432,
        description => 'Database port',
    );

    option name => (
        type        => 'Scalar',
        description => 'Database name',
    );

};

1;

🌸 Including options from another module 🌸

package My::App;
use strict;
use warnings;
use Getopt::Yath;

include_options('My::Options::Database');

option_group {group => 'app', category => 'App Options'} => sub {

    option debug => (
        type        => 'Bool',
        description => 'Debug mode',
    );

};

my $state = parse_options(\@ARGV);
# $state->settings->db->host, ->db->port, etc.
# $state->settings->app->debug

πŸŽ€ Selective inclusion πŸŽ€

and you don't have to take the whole library, bestie β€” you can cherry-pick:

include_options(
    'My::Options::Database' => [qw/host port/],
);

Only the "host" and "port" options will be included; "name" will be excluded. bye felicia name, no shade πŸ’…


πŸ’« POST-PROCESSING πŸ’«

option_post_process registers a callback that runs after all options have been parsed but before environment variables are set. lowkey perfect for validation that depends on multiple options at once:

option_post_process sub {
    my ($instance, $state) = @_;

    my $settings = $state->settings;
    my $server   = $settings->server;

    if ($server->ssl && !$server->cert_file) {
        die "--ssl requires --cert-file\n";
    }
};

⭐ Weighted post-processors ⭐

the first argument is a weight that controls execution order (lower runs first, default is 0). the way that just ✨ works ✨:

option_post_process 10 => sub {
    my ($instance, $state) = @_;
    # Runs after weight-0 post-processors
};

πŸ¦‹ Conditional post-processors πŸ¦‹

an optional "applicable" coderef controls whether the post-processor even runs:

option_post_process 0 => sub { ... if server group exists ... } => sub {
    my ($instance, $state) = @_;
    ...
};

and if you use this inside an "option_group" block, the group's "applicable" is inherited automatically. free real estate, no cap. πŸ”₯


🌈 DYNAMIC OPTIONS WITH mod_adds_options 🌈

some options specify a module name whose options should be dynamically loaded when the option is used. it's giving main character energy tbh:

option runner_class => (
    type             => 'Scalar',
    name             => 'runner',
    field            => 'class',
    default          => 'My::Runner',
    mod_adds_options => 1,
    normalize        => sub { fqmod($_[0], 'My::Runner') },
    description      => 'Runner class to use',
);

When "--runner=My::Custom::Runner" is specified, the module is loaded, and if it has an options() method, those options are included into the current instance. this is how plugins and extensions get to contribute their own command-line options β€” obsessed. πŸ‘‘


🌟 APPLICABILITY 🌟

options can be conditionally shown/hidden based on runtime state, which is so valid of them:

option reloader => (
    type       => 'Auto',
    autofill   => 'My::Reloader',
    applicable => sub {
        my ($opt, $options, $settings) = @_;
        return $settings->runner->preloads && @{$settings->runner->preloads};
    },
    description => 'Module reloader (only available with preloads)',
);

When "applicable" returns false, the option is excluded from parsing and documentation. ghosted, literally. πŸ‘»


πŸ’œ THE SETTINGS OBJECT πŸ’œ

the "$state->settings" value is a Getopt::Yath::Settings object. groups are accessed as methods, so it's just vibes:

$settings->server->host;
$settings->db->port;

Useful methods:

# Check if a group exists
if ($settings->check_group('server')) { ... }

# Safe access with a default
my $val = $settings->maybe('server', 'host', 'localhost');

πŸŽ€ COMPLETE EXAMPLE πŸŽ€

here is a complete script demonstrating many features together β€” it's the full fit, head to toe, and it ate fr fr πŸ“:

#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Yath;

option_group {group => 'app', category => 'Application Options'} => sub {

    option verbose => (
        type          => 'Count',
        short         => 'v',
        initialize    => 0,
        from_env_vars => ['MYAPP_VERBOSE'],
        set_env_vars  => ['MYAPP_VERBOSE'],
        description   => 'Increase verbosity (-v, -vv, -vvv)',
    );

    option config => (
        type        => 'Scalar',
        short       => 'c',
        description => 'Path to config file',
        trigger     => sub {
            my $opt    = shift;
            my %params = @_;
            return unless $params{action} eq 'set';
            my ($file) = @{$params{val}};
            die "Config file not found: $file\n" unless -f $file;
        },
    );

    option output_format => (
        type           => 'Scalar',
        short          => 'f',
        default        => 'json',
        allowed_values => [qw/json csv xml/],
        description    => 'Output format',
    );

    option tags => (
        type        => 'List',
        short       => 't',
        split_on    => ',',
        description => 'Tags to apply (comma-separated)',
    );

    option env_vars => (
        type        => 'Map',
        short       => 'E',
        description => 'Extra environment variables (KEY=VAL)',
    );

    option dry_run => (
        type        => 'Bool',
        short       => 'n',
        default     => 0,
        description => 'Dry run, do not make changes',
    );

};

option_group {group => 'files', category => 'File Options'} => sub {

    option include => (
        type        => 'PathList',
        short       => 'I',
        description => 'Files to include (globs allowed)',
    );

};

category_sort_map(
    'Application Options' => 1,
    'File Options'        => 2,
);

option_post_process sub {
    my ($instance, $state) = @_;
    my $settings = $state->settings;

    if ($settings->app->dry_run && $settings->app->verbose < 1) {
        warn "Note: --dry-run without --verbose; enabling verbose=1\n";
        $settings->app->option(verbose => 1);
    }
};

# Show help if requested
if (grep { $_ eq '--help' || $_ eq '-h' } @ARGV) {
    print options()->docs('cli');
    exit 0;
}

my $state = parse_options(\@ARGV,
    stops         => ['--', '::'],
    skip_non_opts => 1,
);

my $settings = $state->settings;

printf "Verbosity: %d\n", $settings->app->verbose;
printf "Format: %s\n",    $settings->app->output_format;
printf "Dry run: %s\n",   $settings->app->dry_run ? 'yes' : 'no';

if (my $tags = $settings->app->tags) {
    printf "Tags: %s\n", join(', ', @$tags) if @$tags;
}

if (my @files = @{$state->skipped}) {
    printf "Files: %s\n", join(', ', @files);
}

if ($state->stop) {
    printf "Stopped at: %s\n", $state->stop;
    printf "Remaining: %s\n", join(' ', @{$state->remains});
}

πŸ”₯ ADVANCED: MULTI-STAGE PARSING WITH SUBCOMMANDS πŸ”₯

okay bestie so like. tons of tools out there do the whole "script [GLOBAL OPTIONS] subcommand [COMMAND OPTIONS]" thing?? like the script has its own little pile of options, then there's a subcommand name, and then the subcommand got its OWN options too. it's very main character of them. πŸ’…

and Getopt::Yath is fully here for it, via multi-stage parsing: you parse the global options first (and stop dead at the subcommand), then you load the subcommand and parse its options out of whatever arguments are left over. two stages, two vibes, one clean result. ✨

Step 1: Define global options

first up, make a package for your script-level options. your global era fr fr.

package My::CLI;
use strict;
use warnings;
use Getopt::Yath;

option_group {group => 'global', category => 'Global Options'} => sub {

    option verbose => (
        type        => 'Count',
        short       => 'v',
        initialize  => 0,
        description => 'Increase verbosity',
    );

    option config => (
        type        => 'Scalar',
        short       => 'c',
        description => 'Path to config file',
    );

};

Step 2: Define subcommand option packages

now every subcommand gets its own separate package with its own options. independent queens, no sharing. πŸ‘‘

package My::CLI::Command::deploy;
use strict;
use warnings;
use Getopt::Yath;

option_group {group => 'deploy', category => 'Deploy Options'} => sub {

    option target => (
        type        => 'Scalar',
        short       => 't',
        description => 'Deployment target (staging, production)',
    );

    option dry_run => (
        type        => 'Bool',
        short       => 'n',
        default     => 0,
        description => 'Perform a dry run',
    );

};

sub run {
    my ($class, $settings) = @_;
    printf "Deploying to %s (dry_run=%s, verbose=%d)\n",
        $settings->deploy->target // 'default',
        $settings->deploy->dry_run ? 'yes' : 'no',
        $settings->global->verbose;
}

package My::CLI::Command::test;
use strict;
use warnings;
use Getopt::Yath;

option_group {group => 'test', category => 'Test Options'} => sub {

    option jobs => (
        type        => 'Scalar',
        short       => 'j',
        default     => 1,
        description => 'Number of parallel test jobs',
    );

    option tags => (
        type        => 'List',
        split_on    => ',',
        description => 'Filter tests by tag',
    );

};

sub run {
    my ($class, $settings) = @_;
    printf "Running tests with %d jobs (verbose=%d)\n",
        $settings->test->jobs,
        $settings->global->verbose;
}

Step 3: Two-stage dispatch

and now the moment of truth: parse global options first and STOP at the subcommand name. then load the subcommand module, fold its options in, and parse what's left using the combined option set. pass that settings object allll the way through so both stages are literally sharing state. it's giving teamwork. 🫢

package main;

my %commands = (
    deploy => 'My::CLI::Command::deploy',
    test   => 'My::CLI::Command::test',
);

# Stage 1: Parse global options, stop at the subcommand name
my $stage1 = My::CLI::parse_options(
    \@ARGV,
    stop_at_non_opts => 1,
);

my $command_name = $stage1->stop
    or die "Usage: my-cli [global options] <command> [command options]\n";

my $command_class = $commands{$command_name}
    or die "Unknown command: $command_name\n";

# Stage 2: Include command options, parse remaining args
#          Carry forward settings so global values are preserved
My::CLI::include_options($command_class);

my $stage2 = My::CLI::parse_options(
    $stage1->remains,
    settings => $stage1->settings,
);

# Dispatch β€” settings object has both global and command groups
$command_class->run($stage2->settings);

Example invocations

look at her go 😭

$ my-cli -vv deploy --target production --dry-run
Deploying to production (dry_run=yes, verbose=2)

$ my-cli --config app.yml test -j8 --tags smoke,unit
Running tests with 8 jobs (verbose=0)

so the key techniques here β€” the stuff you actually gotta remember β€” are:

  • stop_at_non_opts in Stage 1 stops parsing at the subcommand name, placing it in "$state->stop" and the rest in "$state->remains".
  • include_options merges the subcommand's options into the script's option instance before Stage 2.
  • settings is passed from Stage 1 to Stage 2 so the global values are preserved and both stages write into the same settings object.

πŸ‘€ SEE ALSO πŸ‘€

  • Getopt::Yath β€” Main module documentation and API reference
  • Getopt::Yath::State β€” The parse result object returned by parse_options
  • Getopt::Yath::Option β€” Base option class and all available attributes
  • Getopt::Yath::Settings β€” The parsed settings object
  • Getopt::Yath::Instance β€” Internal option instance (advanced usage)

πŸ“¦ SOURCE πŸ“¦

the source code repository for Getopt-Yath can be found at <http://github.com/Test-More/Getopt-Yath/>.

πŸ’– MAINTAINERS πŸ’–

Chad Granum <exodist@cpan.org>

✨ AUTHORS ✨

Chad Granum <exodist@cpan.org>

Copyright Chad Granum <exodist7@gmail.com>.

This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself.

See <http://dev.perl.org/licenses/>


and that's a wrap bestie πŸŽ€ this whole page is from perl v5.40.1, dated 2026-05-10, and it goes by the name Getopt...torial(3pm). go forth and parse, you absolute icon. no cap. πŸ’…πŸ¦‹

πŸ“¦ where this page comes from