Yet Another CLI framework

Write CLI.pm, not cli.pl

2026-04-074 minperlcommand linecliya-cligetopt-longmoopodmetacpancpanmodule

TL;DR

Searching for CLI modules on MetaCPAN returns 1690 results. And still I wrote another, Yet Another CLI framework. This is about why mine is the one you want to use. To install it: cpanm YA::CLI.

Introduction

CLI clients, we all write them, we all want them, but the boilerplate is just horrible. I wanted to get rid of it: Burn it with 🔥.

At my previous dayjob we had something I wrote, or co-wrote, my boss wanted a sort of chef like API. It became zsknife, but had a huge downside. You needed to register each command in the main module and I didn’t like that at all. I forked the concept internally, made it so you didn’t needed to register, but it lacked discovery.

Unfinished business

When I wanted to write a git helper/client in Perl I took the pattern of my former (or back than current) employer and improved it. The rules where simple:

  1. A CLI module should be just a run() statement
  2. A CLI module should be able to describe itself via POD
  3. A CLI module should be able to trigger a --help and --man
  4. A CLI module didn’t need to subscribe itself
  5. A CLI module should be able to have actions and subactions

The use case for me was to have an API similar to this: Verbs that explain the action you want to execute. Perhaps a noun too, for when it’s about a domain of sorts:

zsh
git lab repo create
git lab mr create
git lab mr update

# Or when the tooling itself is the domain
mytool update
mytool release

I never finished the git helper project… but!

YA::CLI

CLI modules on MetaCPAN, I saw a lot! And I wrote another, hence the name, YA::CLI, Yet Another CLI framework.

How does one use it? The meat of a command or subcommand is this:

perl
package MyApp::CLI::MR::Create;
use Moo;
with 'YA::CLI::ActionRole';

sub action { 'mr' };
sub subaction { 'create' };

# Read Getopt::Long for cli_options
sub cli_options { return ('dry-run|n', 'force|f') }

sub run {
  ...;
}

That’s it. Help, manpages, option parsing (via Getopt::Long), given to you for free. Add a role and you give your app super powers. The action role handles dispatch, you just write the logic. Testing is a plain object instantiation, no subprocess, no @ARGV gymnastics. The hardest part of writing a new command is deciding what it should do.

An example test for another module of mine:

perl
{
  my $log_before = qx{git log --oneline};

  write_file('public/index.xml', $initial_rss);

  my $cmd = Blog::Release::CLI::RSS->new(config => $config);
  $cmd->run();

  my $log_after = qx{git log --oneline};
  is($log_before, $log_after, 'no git commits made');
}

SOS - POD to the rescue

You can also tell YA::CLI where to get the POD from, you just tell it where to get it:

perl
sub usage_pod {
    return 0;    # or undef - pod from the script
    return 1;    # pod from the file itself
    return 'pl'; # similar to 0 or undef
    return '/path/to/file'; # this is used as a file for pod
}

You don’t need to call Usage::Pod yourself.

You can add POD to MyApp::CLI and have a help/man page, similar to what happens when you run git without arguments.

Argument handling

You don’t need @ARGV, you can get the args via $self->_args. All options that don’t have an attribute (via has) are stashed away in $self->_cli_args->{'your-name-here'}.

Try it out

Try it yourself:

zsh
cpanm YA::CLI

Now you write the script:

perl
#!perl
use strict;
use warnings;

# PODNAME: myapp.pl
# ABSTRACT: yet another app

require MyApp::CLI;
MyApp::CLI->run();

You write some glue code:

perl
package MyApp::CLI;
# ABSTRACT: Command-line interface for myapp
use Moo;
use namespace::autoclean;

extends 'YA::CLI';

# you can leave this out if you don't want it:
sub exclude_search_path {
  return ['MyApp::CLI::Role::Base'];
}

1;

And finally your first action handler:

perl
package MyApp::CLI::MyAction;
use Moo;
with 'YA::CLI::ActionRole';

sub action { 'myaction' };

# Read Getopt::Long for cli_options
sub cli_options { return ('dry-run|n', 'force|f') }

sub run {
  ...;
}

Writing CLI scripts just became easy, no need to worry about Getopt::Long, Pod::Usage and other boilerplate. Just write a testable module and you are good to go.

YA::Goodbye