pericmd 048: Showing table data in browser as sortable/searchable HTML table

The latest release of Perinci::CmdLine (1.68) supports viewing program’s output in an external program. And also a new output format is introduced: html+datatables. This will show your program’s output in a browser and table data is shown as HTML table using jQuery and DataTables plugin to allow you to filter rows or sort columns. Here’s a video demonstration:

Your browser does not support the video tag, or WordPress filters the VIDEO element.

If the video doesn’t show, here’s the direct file link.

Advertisements

pericmd 047: Special arguments (1): dry_run

In Rinci, function can express in its metadata that it supports various features or options. These feature-/option-related information will later be passed back to the function during function call in the form of special arguments. These arguments are prefixed with “-” (dash) with predefined names and values, and will only be passed if the function already expresses the support, and if the function accepts named arguments (as hash or hashref).

There are several such special arguments, one that I will cover today is -dry_run.

A function can express that it supports dry-run (simulation) mode, via the dry_run feature inside the features property in the Rinci function metadata:

$SPEC{delete_files} = {
    v => 1.1,
    args => {
        ...
    },
    features => {
        dry_run => 1,
    },
}

The special argument -dry_run need not be declared in the args property. It will automatically be passed when program is run in dry-run mode.

In Perinci::CdmLine, a common command-line option --dry-run will automatically be added if function supports dry_run feature. This means, if user passes --dry-run (or, alternatively, setting DRY_RUN environment variable to true), Perinci::CmdLine will call the function with -dry_run => 1.

If function is passed -dry_run => 1 in the arguments, it should perform the operation but without actually doing it. Lots of programs have this feature, like rsync, make, or svn merge (note: git merge also supports dry-run operation but with options named --no-commit --no-ff instead of --dry-run. They are useful for testing/trial, especially when the associated operation is rather dangerous (like deleting stuffs or sending mass email).

We could, of course, manually define a dry_run argument ourselves. But the advantage of specifying the dry_run feature instead is, aside from standardization and automatic addition of –dry-run and DRY_RUN parsing, is that in transactions, the dry-run functions can have special treatment. We will cover transaction in the future.

Here’s the full example:

#!/usr/bin/env perl

use 5.010;
use strict;
use warnings;
use Log::Any '$log';

use Perinci::CmdLine::Any;

our %SPEC;

$SPEC{delete_files} = {
    v => 1.1,
    args => {
        'file' => {
            schema => ['array*', of=>'str*', min_len=>1],
            req => 1,
            pos => 0,
            greedy => 1,
        },
    },
    features => {dry_run=>1},
};
sub delete_files {
    my %args = @_;
    my $verbose = $args{verbose};

    my $num_success = 0;
    my $num_fail = 0;
    for my $file (@{$args{file}}) {
        $log->infof("Deleting %s ...", $file);
        next if $args{-dry_run};
        if (unlink $file) {
            $num_success++;
        } else {
            $num_fail++;
            $log->warnf("Can't delete %s: %s", $file, $!);
        }
    }

    if ($num_fail == 0) {
        [200, "OK"];
    } elsif ($num_success == 0) {
        [500, "All failed"];
    } else {
        [200, "Some failed"];
    }
}

Perinci::CmdLine::Any->new(url=>'/main/delete_files', log=>1)->run;
% mkdir test
% cd test
% touch file1 file2 file3; mkdir dir1 dir2
% ls
dir1/  dir2/  file1  file2  file3

% ../delete-files --dry-run f*
[pericmd] Dry-run mode is activated
delete-files: Deleting file1 ...
delete-files: Deleting file2 ...
delete-files: Deleting file3 ...
% ls
dir1/  dir2/  file1  file2  file3

% ../delete-files --verbose f*
delete-files: Deleting dir1 ...
delete-files: Can't delete dir1: Is a directory
delete-files: Deleting dir2 ...
delete-files: Can't delete dir2: Is a directory
delete-files: Deleting file1 ...
delete-files: Deleting file2 ...
delete-files: Deleting file3 ...
% ls
dir1/  dir2/

pericmd 046: Customizing table output (2)

Continuing from previous post, if we use Perinci::CmdLine::Classic as a backend, there are a few other options to customize table output. Let’s use the same list-files script, but use the classic backend:

% PERINCI_CMDLINE_ANY=classic ./list-files -v

pericmd046-1

You’ll notice that compared to the default Perinci::CmdLine::Lite’s output (which uses Text::Table::Tiny to produce the table), the Perinci::CmdLine::Classic’s output (which uses Text::ANSITable) is a bit fancier, e.g. colors and boxchars (and/or Unicode characters).

By default, Text::ANSITable colors columns differently according to data type. The second column, since it contains only numbers and thus is a numeric column, is colored cyan by default. While string columns are colored light grey by default.

Of course, like the lite backend, the classic backend supports reordering columns:

% PERINCI_CMDLINE_ANY=classic ./list-files2 -v

pericmd046-2

% PERINCI_CMDLINE_ANY=classic FORMAT_PRETTY_TABLE_COLUMN_ORDERS='[["type","size","links"]]' ./list-files2 -v

pericmd046-3

Aside from FORMAT_PRETTY_TABLE_COLUMN_ORDERS, there’s also FORMAT_PRETTY_TABLE_COLUMN_TYPES:

% PERINCI_CMDLINE_ANY=classic FORMAT_PRETTY_TABLE_COLUMN_TYPES='[{"modified":"date"}]' ./list-files3 -v

pericmd046-4

The mentioned list-files3 is exactly the same as list-files2 except that it adds a column modified containing mtime Unix timestamp of file. By default will be shown as a number (cyan), but with the above FORMAT_PRETTY_TABLE_COLUMN_TYPES hint the column is shown as a date (yellow).

Note that there is some heuristics employed, so if you name the column “mtime” or “something_date”, you don’t have to give any hint to show the column as date.

There is also FORMAT_PRETTY_TABLE_COLUMN_FORMATS to apply some formatting to columns, for example:

% PERINCI_CMDLINE_ANY=classic FORMAT_PRETTY_TABLE_COLUMN_FORMATS='[{"size":[["num",{"style":"kilo"}]]}]' ./list-files2 -v

pericmd046-5

The POD for Data::Format::Pretty::Console describes these options in more details.

Aside from these, the Text::ANSITable module itself provides lots of options to configure its output. For example, to choose border style and color theme:

% PERINCI_CMDLINE_ANY=classic ANSITABLE_BORDER_STYLE="Default::csingle" ANSITABLE_COLOR_THEME="Tint::tint_red" ./list-files2 -v

pericmd046-6

With Text::ANSITable you can also customize cell padding/spacing, column widths, or alignments. You can hide some columns/rows, repeat some columns/rows, or even do conditional styles involving Perl code. For more available options, refer to the POD.

pericmd 045: Customizing table output (1)

Data structures like array of arrays of strings (aoaos), hash, or array of hashes of strings (aohos) will render as tables under Perinci::CmdLine. There are some ways to customize this table output, either from outside the script or from inside the script.

Let’s revisit the list-files script that made an appearance some posts ago (pericmd 039):

#!/usr/bin/env perl

use 5.010;
use strict;
use warnings;

use Perinci::CmdLine::Any;

our %SPEC;

$SPEC{list_files} = {
    v => 1.1,
    args => {
        'verbose' => {
            cmdline_aliases => {v=>{}},
            schema => 'bool',
        },
        'all' => {
            cmdline_aliases => {a=>{}},
            schema => 'bool',
        },
    },
};
sub list_files {
    my %args = @_;
    my $verbose = $args{verbose};
    my $all     = $args{all};

    my @files;
    opendir my($dh), ".";
    for (sort readdir($dh)) {
        next if !$all && /\A\./;
        if ($verbose) {
            my $type = (-l $_) ? "l" : (-d $_) ? "d" : (-f _) ? "f" : "?";
            push @files, {name=>$_, size=>(-s _), type=>$type};
        } else {
            push @files, $_;
        }
    }

    [200, "OK", \@files];
}

my $app = Perinci::CmdLine::Any->new(url => '/main/list_files');
delete $app->common_opts->{verbose};
$app->common_opts->{version}{getopt} = 'version|V';
$app->run;

When we run this script:

% ./list-files -v --format json-pretty
[
   200,
   "OK",
   [
      {
         "name" : "hello",
         "size" : 1131,
         "type" : "f"
      },
      {
         "name" : "list-files",
         "size" : 988,
         "type" : "f"
      },
      {
         "name" : "list-files~",
         "size" : 989,
         "type" : "f"
      },
      {
         "name" : "mycomp",
         "size" : 902,
         "type" : "f"
      },
      {
         "name" : "mycomp2a",
         "size" : 608,
         "type" : "f"
      },
      {
         "name" : "mycomp2b",
         "size" : 686,
         "type" : "f"
      },
      {
         "name" : "mycomp2b+comp",
         "size" : 1394,
         "type" : "f"
      },
      {
         "name" : "pause",
         "size" : 4096,
         "type" : "d"
      },
      {
         "name" : "perl-App-hello",
         "size" : 4096,
         "type" : "d"
      }
   ],
   {}
]

%  ./list-files -v
+----------------+------+------+
| name           | size | type |
+----------------+------+------+
| hello          | 1131 | f    |
| list-files     | 988  | f    |
| list-files~    | 989  | f    |
| mycomp         | 902  | f    |
| mycomp2a       | 608  | f    |
| mycomp2b       | 686  | f    |
| mycomp2b+comp  | 1394 | f    |
| pause          | 4096 | d    |
| perl-App-hello | 4096 | d    |
+----------------+------+------+

Column order

We didn’t specify the ordering of columns, because our data is an array of hashes (instead of array of arrays). But in this case, the order happens to be the way we want (filename, then size and type). By default, the order is asciibetical. But if we modify the script and add another field links (for number of hardlinks):

#!/usr/bin/env perl

use 5.010;
use strict;
use warnings;

use Perinci::CmdLine::Any;

our %SPEC;

$SPEC{list_files} = {
    v => 1.1,
    args => {
        'verbose' => {
            cmdline_aliases => {v=>{}},
            schema => 'bool',
        },
        'all' => {
            cmdline_aliases => {a=>{}},
            schema => 'bool',
        },
    },
};
sub list_files {
    my %args = @_;
    my $verbose = $args{verbose};
    my $all     = $args{all};

    my @files;
    opendir my($dh), ".";
    for (sort readdir($dh)) {
        next if !$all && /\A\./;
        if ($verbose) {
            my $is_sym = (-l $_); # will do an lstat
            my @st = stat($_);
            my $type = $is_sym ? "l" : (-d _) ? "d" : (-f _) ? "f" : "?";
            push @files, {name=>$_, size=>(-s _), type=>$type, links=>$st[3]};
        } else {
            push @files, $_;
        }
    }

    [200, "OK", \@files];
}

my $app = Perinci::CmdLine::Any->new(url => '/main/list_files');
delete $app->common_opts->{verbose};
$app->common_opts->{version}{getopt} = 'version|V';
$app->run;

then the result will be:

% ./list-files -v
+-------+----------------+------+------+
| links | name           | size | type |
+-------+----------------+------+------+
| 1     | hello          | 1131 | f    |
| 1     | list-files     | 988  | f    |
| 1     | list-files2    | 1086 | f    |
| 1     | list-files2~   | 988  | f    |
| 1     | list-files~    | 989  | f    |
| 1     | mycomp         | 902  | f    |
| 1     | mycomp2a       | 608  | f    |
| 1     | mycomp2b       | 686  | f    |
| 1     | mycomp2b+comp  | 1394 | f    |
| 6     | pause          | 4096 | d    |
| 5     | perl-App-hello | 4096 | d    |
+-------+----------------+------+------+

What if we want the name column to stay as the leftmost? Here’s also where the result metadata comes in handy. From inside the script (function), we can embed this formatting hints when returning the enveloped result as follow:

[200, "OK", \@files, {
    format_options => {any => {table_column_orders=>[[qw/name type links size/]]}},
}];

OK, that’s a mouthful. What the code above does is add a key to the result metadata (the fourth element of the enveloped result array, a hash) called format_options. The value of this key is a hash of format names and format specifications. We’ll use any for the format name to apply to any format (but you actually can specify different formatting for text vs for json and so on).

The format specification is another hash containing a key called table_column_orders. This key has a value of array of arrays (to be able to specify multiple tables). One element of that array contains the list of columns for our table: [qw/name type links size/]. Since the output table’s columns match this entry, the order is followed.

Aside from inside the script itself, you can actually specify the ordering from an environment variable (outside the script). For example:

% FORMAT_PRETTY_TABLE_COLUMN_ORDERS='[["size","links","type","name"]]' ./list-files2 -v
+------+-------+------+----------------+
| size | links | type | name           |
+------+-------+------+----------------+
| 1131 | 1     | f    | hello          |
| 988  | 1     | f    | list-files     |
| 1187 | 1     | f    | list-files2    |
| 1086 | 1     | f    | list-files2~   |
| 989  | 1     | f    | list-files~    |
| 902  | 1     | f    | mycomp         |
| 608  | 1     | f    | mycomp2a       |
| 686  | 1     | f    | mycomp2b       |
| 1394 | 1     | f    | mycomp2b+comp  |
| 4096 | 6     | d    | pause          |
| 4096 | 5     | d    | perl-App-hello |
+------+-------+------+----------------+

The value of the environment variable is a JSON-encoded array of arrays, just like in table_column_orders format specification above.

If we use the Perinci::CmdLine::Classic backend (which renders tables using Text::ANSITable), there are a few other options available to customize the table. We’ll discuss this in another blog post.

pericmd 044: Customizing output

The functions we use as backend of our CLI application return pure data structure, and Perinci::CmdLine’s formatter figures out how to best display this information. There are, however, some ways to customize how the output looks in our CLI application by setting some attributes in the result metadata.

As you might remember, result metadata is the fourth element in the enveloped result structure:

[$status, $message, $actual_result, $meta]

The result metadata is a hash (a DefHash actually, but for most purposes you don’t care about the difference). There are some attributes (keys) you can set in this metadata to give hints to Perinci::CmdLine on how to render the result in CLI application.

cmdline.result

The first one is cmdline.result. This sets alternative result to use when in CLI context. For example:

sub func {
    [200, "OK", "foo", {'cmdline.result'=>'bar'}];
}

This way, if you are calling the function, you’ll get “foo” (in the third element), but if this function is run on the command-line, user will see “bar”.

Why would this be useful? An example would be functions that return bool values, like for example user_exists(). In Perl, we probably will only care about getting 1/0. But in CLI, you might want to display a more user-friendly message. So instead of:

% user-exists ujang
0
% user-exists kadek
1

If your function does this:

sub user_exists {
    my %args = @_;
    my $exists = actual_check_for_existence($args{user});
    [200, "OK", $exists, {'cmdline.result' => "User $args{user}" . ($exists ? " exists":"does not exist")}];
}

then you can have:

% user-exists ujang
User ujang does not exist
% user-exists kadek
User kadek exists

Another example where this is applied is in
Git::Bunch
. In function check_bunch, the result is a hash of every repo in the bunch and their check statuses, e.g.:

[200, "OK", {repo1=>[200,"clean"], repo2=>[500,"Needs commit"], ...}]

The function also happens to use progress bar to report unclean repositories as the checking is being done. Unclean repos get reported/logged to the screen. Thus, it is not very useful to display this hash on the CLI (but useful when we are using the function from Perl). So check_bunch() sets the CLI output to empty string:

[200, "OK", ..., {'cmdline.result'=>''}]

cmdline.default_format

This attribute picks the default format. For example:

[200, "OK", ..., {'cmdline.default_format'=>'json'}]

This way, when CLI is run, the output defaults to JSON instead of text, unless user explicitly specify the output format that she wants, e.g. --format text.

One common use-case for this is to force the simple or pretty version of text format. By default, for DWIM-ness, the text format becomes simpler when the program is run through pipes (e.g. formatted ASCII table becomes lines of tab-separated values). For example (I’m using the list-files script mentioned in pericmd 039):

% list-files -v
+----------------+------+------+
| name           | size | type |
+----------------+------+------+
| hello          | 1131 | f    |
| list-files     | 988  | f    |
| list-files2    | 1187 | f    |
| mycomp         | 902  | f    |
| mycomp2a       | 608  | f    |
| mycomp2b       | 686  | f    |
| mycomp2b+comp  | 1394 | f    |
| pause          | 4096 | d    |
| perl-App-hello | 4096 | d    |
+----------------+------+------+

% list-files -v | cat
hello   1131    f
list-files      988     f
list-files2     1187    f
mycomp  902     f
mycomp2a        608     f
mycomp2b        686     f
mycomp2b+comp   1394    f
pause   4096    d
perl-App-hello  4096    d

Sometimes you always want to default to the pretty version (even though your CLI program is run through pipes), and sometimes the other way around. To do this you can instruct in the result metadata 'cmdline.default_format' => 'text-pretty' (or text-simple).

Note that the cmdline.default_format attribute can also be specified in the Rinci function metadata, but specifying this in the result metadata is more flexible as we can customize on a per-invocation basis.

cmdline.exit_code

This is not actually related to output format, but somewhat related. This attribute explicitly chooses an exit code for the CLI program. By default, as you might also remember, status code is determined as follow: “if status is 2xx or 304, then 0, else status-300”.

cmdline.skip_format

If you set this attribute to true, the result will be printed as-is without any formatting. You might want to use this if you are outputting a preformatted text. Which defeats the whole point of convenience given by Perinci::CmdLine, but sometimes it’s useful.

cmdline.page_result and cmdline.pager

This is also not directly related to formatting, but somewhat related. If you set cmdline.page_result to true, you can instruct Perinci::CmdLine to run a pager (like less). This might be useful for programs that output long text. The cmdline.pager can be used to specifically choose another program instead of the default $ENV{PAGER} (or less).

In the next blog post I’ll discuss more ways to customize table output.

pericmd 043: Generating CLI applications (App::GenPericmdScript)

Most Perinci::CmdLine-based CLI scripts are basically a variation of:

#!perl

use Perinci::CmdLine::Any;
Perinci::CmdLine->new(
    url => '/some/riap/url/to/function',
    ...
)->run;

Due to my laziness and strict adherence to the DRY principle, I create a script gen-pericmd-script (distributed with App::GenPericmdScript) to generate this boilerplate. To see it in action, first install Perinci::Examples (if you haven’t done so) and then run:

% gen-pericmd-script /Perinci/Examples/gen_array

The result is spewed to standard output:

#!/mnt/home/s1/perl5/perlbrew/perls/perl-5.18.4/bin/perl

# Note: This script is a CLI interface to Riap function /Perinci/Examples/gen_array
# and generated automatically using App::GenPericmdScript version 0.04

# DATE
# VERSION

use 5.010001;
use strict;
use warnings;

use Perinci::CmdLine::Any;

Perinci::CmdLine::Any->new(
    url => "/Perinci/Examples/gen_array",
)->run;

# ABSTRACT: Generate an array of specified length
# PODNAME: script

If you analyze the output, the abstract is also written for you. This is taken from the Rinci metadata which is retrieved by gen-pericmd-script via a Riap meta request.

If you specify -o option, e.g. -o /home/s1/bin/gen-array, the generated script is written to the specified path and also set chmod 0755 as well as tab completion is activated (if you have shcompgen installed). There are of course several options to customize the script, like the Perinci::CmdLine backend module to use, whether to activate logging, specify subcommands, whether to add some code before instantiating Perinci::CmdLine object, and so on.

App::GenPericmdScript is actually best used with Dist::Zilla. There’s a plugin called DZP:Rinci::ScriptFromFunc which uses to App::GenPericmdScript to generate scripts for you during build. If some have a dist.ini like this:

name=App-GenArray
version=0.01

[Rinci::ScriptFromFunc]
script= url=/Perinci/Examples/gen_array

[@Classic]

[PodWeaver]
config_plugin=-Rinci

After you run dzil build, you’ll get something like this in App-GenArray-0.01/bin/gen-array:

#!perl

# Note: This script is a CLI interface to Riap function /Perinci/Examples/gen_array
# and generated automatically using App::GenPericmdScript version 0.04

# DATE
# VERSION

use 5.010001;
use strict;
use warnings;

use Perinci::CmdLine::Any;

$ENV{PERL_LWP_SSL_VERIFY_HOSTNAME} = 0;

Perinci::CmdLine::Any->new(
    url => "/Perinci/Examples/gen_array",
)->run;

# ABSTRACT: Generate an array of specified length
# PODNAME: gen-array

__END__

=pod

=head1 SYNOPSIS

Usage:

 % gen-array [options] <len>

=head1 DESCRIPTION

Also tests result schema.

=head1 OPTIONS

C<*> marks required options.

=over

=item B<--config-path>=I<s>

Set path to configuration file.

Can be specified multiple times.

=item B<--config-profile>=I<s>

Set configuration profile to use.

=item B<--format>=I<s>

Choose output format, e.g. json, text.

=item B<--help>, B<-h>, B<-?>

Display this help message.

=item B<--json>

Set output format to json.

=item B<--len>=I<i>*

Array length.

Default value:

 10

=item B<--naked-res>

When outputing as JSON, strip result envelope.

By default, when outputing as JSON, the full enveloped result is returned, e.g.:

    [200,"OK",[1,2,3],{"func.extra"=>4}]

The reason is so you can get the status (1st element), status message (2nd
element) as well as result metadata/extra result (4th element) instead of just
the result (3rd element). However, sometimes you want just the result, e.g. when
you want to pipe the result for more post-processing. In this case you can use
`--naked-res` so you just get:

    [1,2,3]


=item B<--no-config>

Do not use any configuration file.

=item B<--version>, B<-v>

=back

=head1 ENVIRONMENT

GEN_ARRAY_OPT

=head1 FILES

~/gen-array.conf

/etc/gen-array.conf

=cut

When you run perldoc on this script, you’ll get something like:

GEN-ARRAY(1)               User Contributed Perl Documentation               GEN-ARRAY(1)



SYNOPSIS
       Usage:

        % gen-array [options] <len>

DESCRIPTION
       Also tests result schema.

OPTIONS
       "*" marks required options.

       --config-path=s
           Set path to configuration file.

           Can be specified multiple times.

       --config-profile=s
           Set configuration profile to use.

       --format=s
           Choose output format, e.g. json, text.

       --help, -h, -?
           Display this help message.

       --json
           Set output format to json.

       --len=i*
           Array length.

           Default value:

            10

       --naked-res
           When outputing as JSON, strip result envelope.

           By default, when outputing as JSON, the full enveloped result is returned,
           e.g.:

               [200,"OK",[1,2,3],{"func.extra"=>4}]

           The reason is so you can get the status (1st element), status message (2nd
           element) as well as result metadata/extra result (4th element) instead of just
           the result (3rd element). However, sometimes you want just the result, e.g.
           when you want to pipe the result for more post-processing. In this case you
           can use `--naked-res` so you just get:

               [1,2,3]

       --no-config
           Do not use any configuration file.

       --version, -v

ENVIRONMENT
       GEN_ARRAY_OPT

FILES
       ~/gen-array.conf

       /etc/gen-array.conf

pericmd 042: Using functions from other languages

Since Perinci::CmdLine uses Riap behind the scenes (from getting the Rinci metadata to calling the function), it is possible to use a remote server as the Riap server, even when the server side is not Perl. Below are two examples. The first one uses piping (stdin/stdout) to access a Ruby program on the same server, and the second one uses TCP server written in Node.js. Note that the two programs are just quick-hacks and very ad-hoc, I haven’t actually developed any Riap libraries on those languages. Their main goal is to demonstrate the simplicity of the Riap::Simple protocol.

Ruby over pipe

Save this code to /some/path/to/riap_server.rb:

#!/usr/bin/env ruby

require 'json'

def _res(res)
  res[3] ||= {}
  res[3]['riap.v'] ||= 1.1
  puts "j" + res.to_json
  $stdout.flush
end

while line = $stdin.gets do
  if line =~ /^j(.+)/
    begin
      req = JSON.parse($1)
    rescue Exception => e
      _res [400, "Invalid JSON in Riap request: " + e.message]
      next
    end

    if !req['action']
      _res [400, "Please specify 'action'"]
      next
    end

    if !req['uri']
      _res [400, "Please specify 'uri'"]
      next
    end

    if req['action'] == 'call'
      if req['uri'] == '/cat_array'
        args = req['args'] || {}
        if (!args['a1'])
          _res [400, "Please specify a1"]
          next
        elsif (!args['a2'])
          _res [400, "Please specify a1"]
          next
        end
        _res [200,"OK",args['a1'] + args['a2']]
        next
      else
        _res [404, "Unknown uri"]
        next
      end

    elsif req['action'] == 'meta'
      if req['uri'] == '/cat_array'
        _res [200,"OK",{
                "v" => 1.1,
                "summary" => "Concatenate two arrays together",
                "args" => {
                  "a1" => {
                    "summary" => "First array",
                    "schema" => ["array"],
                    "req" => true,
                  },
                  "a2" => {
                    "summary" => "Second array",
                    "schema" => ["array"],
                    "req" => true,
                  },
                }}]
        next
      else
        _res [404, "Unknown uri"]
        next
      end

    elsif req['action'] == 'info'
      if req['uri'] == '/cat_array'
        _res [200,"OK",{"type" => "function", "uri" => "/foo"}]
        next
      else
        _res [404, "Unknown uri"]
        next
      end

    else
      _res [400, "Invalid action"]
      next
    end

  else
    _res [400, "Invalid Riap request"]
    break
  end
end

Now create our CLI program, let’s call it cat-array-ruby:

#!/usr/bin/env perl

use Perinci::CmdLine::Classic;
Perinci::CmdLine::Classic->new(
    url => "riap+pipe:/some/path/to/riap_server.rb////cat_array",
)->run;

Let’s test the CLI program:

% cat-array-ruby --help
cat-array-ruby - Concatenate two arrays together                                                     
Usage                                                                                    
  -e --help (or -h, -?)                                                                  
  -e --version (or -v)                                                                   
  -e [options]                                                                           
Options                                                                                  
  --a1-json=s                                --a1-yaml=s                                 
  --a1=s*                                    --a2-json=s                                 
  --a2-yaml=s                                --a2=s*                                     
  --config-path=s                            --config-profile=s                          
  --debug                                    --format-options=s                          
  --format=s                                 --help, -h, -?                              
  --json                                     --log-level=s                               
  --no-config                                --quiet                                     
  --trace                                    --verbose                                   
  --version, -v                                                                          
For more complete help, use '--help --verbose'.                   

% cat-array-ruby --a1-json '[1,2,3]' --a2-json '[4,5,6]'
┌─────────────────────────────┐
│  1    2    3    4    5    6 │
└─────────────────────────────┘

All the other features you would normally get from a Perinci::CmdLine-based CLI application, like tab completion, output formatting, and so on works.

Node.js over TCP server

Save this code to riap_server.js:

function _res(s, res) {
    if (!res[3]) res[3] = {};
    res[3]['riap.v'] = 1.1;
    s.write("j" + JSON.stringify(res) + "\015\012");
    return;
}

var humanize = require('humanize');
var net = require('net');
var rl = require('readline');
var server = net.createServer(function(socket) { //'connection' listener
    console.log('client connected');
    socket.on('end', function() {
        console.log('client disconnected');
    });
    var i = rl.createInterface(socket, socket);
    i.on('line', function (line) {
        match = line.match(/^j(.+)/)
        if (match) {
            // XXX error handling?
            var req = JSON.parse(match[1]);
            if (!req['action']) {
                _res(socket, [400, "Please specify action"]);
            } else if (!req['uri']) {
                _res(socket, [400, "Please specify uri"]);

            } else if (req['action'] == 'call') {
                var args = req['args'] || {}
                if (req['uri'] == '/humanize/filesize') {
                    if (!args['size']) {
                        _res(socket, [400, "Please specify size"]);
                    } else {
                        _res(socket, [200, "OK", humanize.filesize(args['size'])]);
                    }
                } else {
                    _res(socket, [404, "Unknown uri"]);
                }

            } else if (req['action'] == 'meta') {
                if (req['uri'] == '/humanize/filesize') {
                    _res(socket, [200, "OK", {
                        "v": 1.1,
                        "summary": "Humanize file size",
                        "args": {
                            "size": {
                                "schema": ["int"],
                                "req": true,
                                "pos": 0
                            }
                        }
                    }]);
                } else {
                    _res(socket, [404, "Unknown uri"]);
                }

            } else if (req['action'] == 'info') {
                if (req['uri'] == '/humanize/filesize') {
                    _res(socket, [200, "OK", {"uri":"/humanize/filesize", "type":"function"}])
                } else {
                    _res(socket, [404, "Unknown uri"]);
                }

            } else {
                _res(socket, [400, "Unknown action"]);
            }
        } else {
            _res(socket, [400, "Invalid Riap request"]);
            socket.destroy();
        }
    });
});
server.listen(5000, function() { //'listening' listener
    console.log('server bound');
});

Install the humanize NPM module (if you doesn’t have the module) and run the server:

% npm install humanize
% node riap_server.js
server bound

Prepare our client, let’s call it humanize-filesize:

#!/usr/bin/env perl

use Perinci::CmdLine::Classic;
Perinci::CmdLine::Classic->new(
    url => "riap+tcp://localhost:5000/humanize/filesize",
)->run;

Run our CLI:

% humanize-filesize --help
humanize-filesize - Humanize file size                                      
Usage                                                                                    
  -e --help (or -h, -?)                                                                  
  -e --version (or -v)                                                                   
  -e [options] <size>                                                                    
Options                                                                                  
  --config-path=s                            --config-profile=s                          
  --debug                                    --format-options=s                          
  --format=s                                 --help, -h, -?                              
  --json                                     --log-level=s                               
  --no-config                                --quiet                                     
  --size=i* (=arg[0])                        --trace                                     
  --verbose                                  --version, -v                               
For more complete help, use '--help --verbose'.                        

% humanize-filesize
ERROR 400: Missing required argument(s): size

% humanize-filesize 100200300
95.56 MB

% humanize-filesize 100200300 --json
[
   200,
   "OK",
   "95.56 MB",
   {
      "riap.v": 1.1
   }
]

Note that in this blog post we are using Perinci::CmdLine::Classic instead of Perinci::CmdLine::Any because the default backend Perinci::CmdLine::Lite does not yet support the URL schemes riap+pipe:/ or riap+tcp://. This will be rectified sometime in the future.