This workshop session is designed to give you a complete introduction to the core features of Airline for creating powerful CLIs. This was originally written for the Tech Exeter Conference series and presented in September 2018.
The workshop is provided as a HTML slideshow embedded below, use the arrow keys to navigate through the slides. You can view this in fullscreen by hitting the icon in the top left of the slides.
Everyone builds command line applications at some point but often they are cobbled together or full of needless boiler plate. Airline takes a fully declarative approach to command line applications allowing users to create powerful and flexible command lines.
Airline takes care of lots of heavy lifting providing many features not found in similar libraries including annotation driven value validation restrictions, generating Man pages and Bash completion scripts to name just a few.
In the workshop session we’ll work through an example command line application to see just how powerful this can be.
In order to follow along with this workshop we assume the following knowledge and tools:
git
installedmvn
installedNB 2-4 will allow you to run the examples shown in the slides but aren’t essential.
A Linux/Mac laptop is preferred but you should be able to use Windows for most things.
You can find these slides at http://rvesse.github.io/airline/guide/practise/workshop.html
Firstly we want to define our command lines using declarative annotations.
This allows us to separate the command line definition cleanly from the runtime logic.
It also enables us to do optional build time checking of our definitions to ensure valid command line apps.
Secondly we look to avoid the typical boiler plate code associated with many command line libraries.
You shouldn’t need to write a ton of if
statements to check that values for options fall in specified ranges or
meet common application constraints.
Finally we don’t want to tie you into a particular implementation approach.
We provide extensibility of almost every aspect of the parsing process yet provide a general purpose default setup that should suit many users.
So a basic CLI should just work, advanced CLIs can be configured as desired
Due to time constraints this will be more of an interactive demo, almost everything in these slides is in the GitHub repo so you can play along.
For this workshop we are going to build an example command line application called send-it
for shipping of packages.
The example code in these slides is typically truncated to omit things like import declarations for brevity, the full
code is linked alongside each example.
The example code all lives inside the Airline git repository at https://github.com/rvesse/airline/tree/master/airline-examples
We use >
to indicate that a command should be run at a command prompt and <input>
within that to indicate some input
is needed.
To follow along you should start by checking out the code and building the examples:
> git clone https://github.com/rvesse/airline.git
> cd airline
> mvn package
Many of the examples are runnable using the runExample
script in the airline-examples
sub-directory e.g.
> cd airline-examples
> ./runExample SendIt <options>
Or for this specific workshop the send-it
script in that same sub-directory can be used:
> ./send-it <options>
Or for Windows Users:
> send-it.bat <options>
Airline works with POJOs (Plain Old Java Objects) so firstly we need to define some classes that are going to hold our commands options.
We can define our options across multiple classes and our inheritance hierarchy i.e. you can create a BaseCommand
with
your common options.
Or you can define options in standalone classes and compose them together.
We’re going to see the latter approach in this workshop, see Inheritance and Composition for more detail on the former approach.
@Option
The @Option
annotation is used to mark a field as being populated by an option. At a
minimum it needs to define the name
field to provide one/more names that your users will enter to refer to your option
e.g.
@Option(name = { "-e", "--example" })
private String example;
Here we define a simple String
field and annotate it with @Option
providing two possible names - -e
and
--example
- by which users can refer to it.
Other commonly used fields in the @Option
annotation include title
used to specify the title by which the value is
referred to in help and description
used to provide descriptive help information for the option.
PostalAddress
exampleLet’s take a look at the PostalAddress
class which defines options for specifying a UK postal address. Explanatory text is interspersed into the example:
public class PostalAddress {
@Option(name = "--recipient", title = "Recipient",
description = "Specifies the name of the receipient")
@Required
public String recipient;
So we start with a fairly simply definition, this defines a --recipient
option and states that it is a required option
via the @Required
annotation.
@Option(name = "--number", title = "HouseNumber",
description = "Specifies the house number")
@RequireOnlyOne(tag = "nameOrNumber")
@IntegerRange(min = 0, minInclusive = false)
public Integer houseNumber;
@Option(name = "--name", title = "HouseName",
description = "Specifies the house name")
@RequireOnlyOne(tag = "nameOrNumber")
@NotBlank
public String houseName;
Now we’re starting to get more advanced, here we have two closely related options - --number
and --name
- which we
declare that we require only one of via the @RequireOnlyOne
i.e. we’ve told
Airline that one, and only one, of these two options may be specified.
Additionally for the --number
option we state that it must be greater than zero via the
@IntegerRange
annotation and for the --name
option we state that it must be
@NotBlank
i.e. it must have a non-empty value that is not all whitespace.
Here we have an option that may be specified multiple times to provide multiple address lines. Importantly we need
to define it with an appropriate Collection
based type, in this case List<String>
in order to collect all the
address lines specified.
@Option(name = { "-a", "--address", "--line" }, title = "AddressLine",
description = "Specifies an address line. Specify this multiple times to provide multiple address lines, these should be in the order they should be used.")
@Required
@MinOccurrences(occurrences = 1)
public List<String> addressLines = new ArrayList<>();
Here we also use the @MinOccurences
annotation to state that it must occur at
least once in addition to using the previously seen @Required
@Option(name = "--postcode", title = "PostCode",
description = "Specifies the postcode")
@Required
@Pattern(pattern = "^([A-Z]{1,2}([0-9]{1,2}|[0-9][A-Z])) (\\d[A-Z]{2})$",
description = "Must be a valid UK postcode.",
flags = java.util.regex.Pattern.CASE_INSENSITIVE)
public String postCode;
Here is another example of a complex restriction, this time we use the @Pattern
annotation to enforce a regular expression to validate our postcodes meet the UK format.
And finally we have some regular Java code in our class. Your normal logic can co-exist happily alongside your Airline annotations, we’ll see this used later to implement our actual command logic.
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append(this.recipient);
builder.append('\n');
if (this.houseNumber != null) {
builder.append(Integer.toString(this.houseNumber));
builder.append(' ');
} else {
builder.append(this.houseName);
builder.append('\n');
}
for (String line : this.addressLines) {
builder.append(line);
builder.append('\n');
}
builder.append(this.postCode);
return builder.toString();
}
}
@Arguments
The @Arguments
annotation is used to annotate a field that will receive arbitrary
inputs i.e. anything that is not recognised as an option as defined by your @Option
annotations. This is useful when
your command wants to operate on a list of things so is typically used in conjunction with a Collection
typed field
e.g. List<String>
.
@Arguments
in useFor example let’s take a look at it in use in the CheckPostcodes
command:
@Arguments(title = "PostCode", description = "Specifies one/more postcodes to validate")
@Required
@MinOccurrences(occurrences = 1)
@Pattern(pattern = "^([A-Z]{1,2}([0-9]{1,2}|[0-9][A-Z])) (\\d[A-Z]{2})$",
description = "Must be a valid UK postcode.",
flags = java.util.regex.Pattern.CASE_INSENSITIVE)
public List<String> postCodes = new ArrayList<>();
Which we can run like so:
> ./send-it check-postcodes "BS1 4DJ" "RG19 6HS"
BS1 4DJ is a valid postcode
RG19 6HS is a valid postcode
So we’ve already seen a number of Restrictions in the above examples. This is one of the main ways Airline reduces boiler plate and prefers declarative definitions. There are lots more built-in restrictions than just those seen so far and you can define Custom Restrictions if you want to encapsulate reusable restriction logic.
Some useful common restrictions include:
@Required
- For required options/arguments@NotBlank
- To enforce non-blank string values@AllowedRawValues
/@AllowedValues
-
To restrict options/arguments to a set of acceptable values@Path
- Provides restrictions on options/arguments used to refer to files and directoriesSo now we’ve seen the basics of defining options and arguments lets use these to define a command:
@Command(name = "send", description = "Sends a package")
public class Send implements ExampleRunnable {
@AirlineModule
private PostalAddress address = new PostalAddress();
@AirlineModule
private Package item = new Package();
@Option(name = { "-s",
"--service" }, title = "Service", description = "Specifies the postal service you would like to use")
private PostalService service = PostalService.FirstClass;
@Override
public int run() {
// TODO: In a real world app actual business logic would go here...
System.out.println(String.format("Sending package weighing %.3f KG sent via %s costing £%.2f", this.item.weight,
this.service.toString(), this.service.calculateCost(this.item.weight)));
System.out.println("Recipient:");
System.out.println();
System.out.println(this.address.toString());
System.out.println();
return 0;
}
public static void main(String[] args) {
SingleCommand<Send> parser = SingleCommand.singleCommand(Send.class);
try {
Send cmd = parser.parse(args);
System.exit(cmd.run());
} catch (ParseException e) {
System.err.print(e.getMessage());
System.exit(1);
}
}
}
There’s quite a few new concepts introduced here, so let’s break them down piece by piece.
@Command
The @Command
annotation is used on Java classes to state that a class is a command. Let’s see our previously introduced PostalAddress
class combined into an actual command, here we see the Send
:
@Command(name = "send", description = "Sends a package")
public class Send implements ExampleRunnable {
The @Command
annotation is fairly simple, we simply have a name
for our command and a description
. The name
is
the name users will use to invoke the command, this name can be any string of non-whitespace characters and is the only
required field of the @Command
annotation.
The description
field provides descriptive text about the command that will be used in help output, we’ll see this
used later.
@AirlineModule
for compositionOften for command line applications you want to define reusable sets of closely related options as we already saw with
the PostalAddress
class. Airline provides a composition mechanism that makes this easy to do.
@AirlineModule
private PostalAddress address = new PostalAddress();
@AirlineModule
private Package item = new Package();
Here we compose the previously seen PostalAddress
class into our command, we use the
@AirlineModule
annotation to indicate to Airline that it should find options declared by
that class. We also have another set of options defined in a separate class, this time the Package
class is used to provide options relating to the
package being sent.
As well as composing options defined in other classes we can also define options specific to a command directly in our command class:
@Option(name = { "-s",
"--service" }, title = "Service", description = "Specifies the postal service you would like to use")
private PostalService service = PostalService.FirstClass;
Here the command declares an additional option -s/--service
that is specific to this command. Here the field actual
has an enum type (PostalService
)
which Airline happily copes with.
For more details on how Airline supports differently typed fields see the Supported Types documentation.
@Override
public int run() {
// TODO: In a real world app actual business logic would go here...
System.out.println(String.format("Sending package weighing %.3f KG sent via %s costing £%.2f",
this.item.weight, this.service.toString(), this.service.calculateCost(this.item.weight)));
System.out.println("Recipient:");
System.out.println();
System.out.println(this.address.toString());
System.out.println();
return 0;
}
}
Finally we have the actual business logic of our class. In this example application it simply prints out some information but this serves to show that we can access the fields that have been populated by the users command line inputs.
In order to actually invoke our command we need to get a parser from Airline and invoke it on the user input. In this
example we do this in our main(String[] args)
method:
public static void main(String[] args) {
SingleCommand<Send> parser = SingleCommand.singleCommand(Send.class);
We call the static SingleCommand.singleCommand()
method passing in the command class we want to get a parser for.
try {
Send cmd = parser.parse(args);
We can then invoke the parse()
method passing in our users inputs.
System.exit(cmd.run());
Assuming the parsing is successful we now have an instance of our Send
class which we can invoke methods on like any
other Java object. In this example our business logic is in the run()
method so we simply call that method and use
its return value as the exit code.
} catch (ParseException e) {
System.err.print(e.getMessage());
System.exit(1);
}
}
Finally if the parsing goes wrong we print the error message and exit with a non-zero return code.
Try this out now:
> ./send-it send --recipient You --number 123 -a "Your Street" -a "Somewhere" --postcode "AB12 3CD" -w 0.5
Sending package weighing 0.500 KG sent via FirstClass costing £0.50
Recipient:
You
123 Your Street
Somewhere
AB12 3CD
> echo $?
0
Typically real world command line interfaces (CLIs) consist of multiple commands e.g. git
Airline allows multiple commands to be composed together into a CLI to support complex applications.
@Cli
We use the @Cli
annotation to define a CLI, this is applied to classes similar to
@Command
e.g. the SendItCli
class:
@Cli(name = "send-it",
description = "A demonstration CLI around shipping",
commands = {
CheckAddress.class,
CheckPostcodes.class,
Send.class,
Price.class,
Help.class,
BashCompletion.class
},
defaultCommand = Help.class,
parserConfiguration = @Parser(
useDefaultOptionParsers = true,
defaultParsersFirst = false,
optionParsers = { ListValueOptionParser.class },
errorHandler = CollectAll.class
)
)
public class SendItCli {
}
Let’s break that down a bit…
As we saw with @Command
this is pretty self-explanatory:
@Cli(name = "send-it",
description = "A demonstration CLI around shipping",
The name
is the name that you expect users to type at the command line, typically you’ll create a Shell script named
this which invokes your actual Java application.
As seen previously description
is used to provide descriptive text that will get included in help output.
commands = {
CheckAddress.class,
CheckPostcodes.class,
Send.class,
Price.class,
Help.class,
BashCompletion.class
},
defaultCommand = Help.class,
The commands
field of the annotation defines the classes that provide your commands. Each of these must be
appropriately annotated with @Command
.
We also see the defaultCommand
field used to indicate what command is invoked if the user doesn’t invoke a command.
This can be useful to provide default behaviour and is often used to point to the help system.
NB - Generally it is useful for all your commands to have a common parent class or interface since as we’ll see in a
few slides time we’ll need to declare a type when creating a parser. In this case all our commands implement ExampleRunnable
Here we see the parser being customised, we’re going to skip over most of this for now and come back to it later:
parserConfiguration = @Parser(
useDefaultOptionParsers = true,
defaultParsersFirst = false,
optionParsers = { ListValueOptionParser.class },
errorHandler = CollectAll.class
)
The one important thing to point out here is we are changing the errorHandler
to CollectAll
which will allow us to
more intelligently handle errors later.
So you probably noticed we had zero logic in the class defining our CLI, similar to our single command example we need
to define an appropriate main()
method for our CLI. This we do in the SendIt
class:
public class SendIt {
public static void main(String[] args) {
Cli<ExampleRunnable> parser = new Cli<ExampleRunnable>(SendItCli.class);
try {
// Parse with a result to allow us to inspect the results of parsing
ParseResult<ExampleRunnable> result = parser.parseWithResult(args);
if (result.wasSuccessful()) {
// Parsed successfully, so just run the command and exit
System.exit(result.getCommand().run());
} else {
// Parsing failed
// Display errors and then the help information
System.err.println(String.format("%d errors encountered:", result.getErrors().size()));
int i = 1;
for (ParseException e : result.getErrors()) {
System.err.println(String.format("Error %d: %s", i, e.getMessage()));
i++;
}
System.err.println();
Help.<ExampleRunnable>help(parser.getMetadata(), Arrays.asList(args), System.err);
}
} catch (Exception e) {
// Errors should be being collected so if anything is thrown it is unexpected
System.err.println(String.format("Unexpected error: %s", e.getMessage()));
e.printStackTrace(System.err);
}
// If we got here we are exiting abnormally
System.exit(1);
}
}
Once again there’s a lot going on, so let’s break it down…
Cli<ExampleRunnable> parser = new Cli<ExampleRunnable>(SendItCli.class);
So firstly we create an instance of the Cli
class, not to be confused with the @Cli
annotation, referring to our previously introduced class with the @Cli
annotation.
As mentioned we need to define a type for the commands that will be parsed. So this is where it is helpful to have all your commands inherit from a common parent class or implement a common interface.
NB You can always use Object
here as all Java objects derive from this but this will make the rest of your implementation awkward!
// Parse with a result to allow us to inspect the results of parsing
ParseResult<ExampleRunnable> result = parser.parseWithResult(args);
Here we call the parseWithResult()
method passing in the user arguments received by our main()
method. This will
give us a ParseResult
instance that we can inspect to see if parsing succeeded:
if (result.wasSuccessful()) {
// Parsed successfully, so just run the command and exit
System.exit(result.getCommand().run());
Assuming successful parsing we can simply call getCommand()
on our result
and then invoke its run()
method since
all our commands implement a common interface.
Alternatively we could have just called parse(args)
which would return either the parsed command, throw an exception
or return null
depending on the user inputs and the parser configuration.
If parsing wasn’t successful then we need to do something about that. We specified a different error handler earlier that allows us to collect up all the errors:
} else {
// Parsing failed
// Display errors and then the help information
System.err.println(String.format("%d errors encountered:", result.getErrors().size()));
int i = 1;
for (ParseException e : result.getErrors()) {
System.err.println(String.format("Error %d: %s", i, e.getMessage()));
i++;
}
So we loop over all the errors printing them out
System.err.println();
Help.<ExampleRunnable>help(parser.getMetadata(), Arrays.asList(args), System.err);
}
Followed by invoking the help system to display the help for the CLI.
As noted earlier we usually want to create an entry point shell script for our CLI that matches the name declared in our
@Cli
annotation.
Here’s the contents of send-it
:
#!/usr/bin/env bash
JAR_FILE="target/airline-examples.jar"
if [ ! -f "${JAR_FILE}" ]; then
echo "Examples JAR ${JAR_FILE} does not yet exist, please run mvn package to build"
exit 1
fi
java -cp "${JAR_FILE}" com.github.rvesse.airline.examples.sendit.SendIt "$@"
To run our CLI we just need to invoke the script i.e.
> ./send-it
send-it
Since we defined our default command to be the help command we get useful output:
> ./send-it
usage: send-it <command> [ <args> ]
Commands are:
check-address Check if an address meets our restrictions
check-postcodes Checks whether postcodes are valid
generate-completions Generates a Bash completion script, the file can then be sourced to provide completion for this CLI
help A command that provides help on other commands
price Calculates the price for a parcel
send Sends a package
See 'send-it help <command>' for more information on a specific command.
Why not try asking for help on the send
command we saw earlier:
> ./send-it help send
So how did that last demo work?
Airline includes a help system that can generate help in a variety of formats plus prebuilt commands and options that can be added into your commands/CLIs.
We can invoke help in a number of ways:
HelpOption
into our commandsHelp
command into our CLIsLet’s see the difference between each.
HelpOption
to our commandsThis is a pre-built class which defines a -h
/--help
option, therefore we can compose this using
@AirlineModule
as seen earlier:
@Command(name = "simple", description = "A simple example command")
public class Simple implements ExampleRunnable {
@AirlineModule
private HelpOption<Simple> help;
// Rest of implementation omitted for brevity
@Override
public int run() {
if (help.showHelpIfRequested())
return 0;
System.out.println("Flag was " + (this.flag ? "set" : "not set"));
System.out.println("Name was " + this.name);
System.out.println("Number was " + this.number);
if (args != null)
System.out.println("Arguments were " + StringUtils.join(args, ","));
return 0;
}
}
We can see in the run()
method we call the showHelpIfRequested()
method to check if the user requested help. If
this returns true
then help was requested and has been shown so we just exit. If this returns false
then we
continue with our normal logic.
Let’s try that:
> ./runExample Simple
Flag was not set
Name was null
Number was 0
> ./runExample Simple --help
NAME
simple - A simple example command
...
Help
commandAirline includes a pre-built Help
command that implements Runnable
and Callable<Void>
. If either of these are the common interface for your commands you can simply add this to your commands in your @Cli
declaration e.g.
@Cli(name = "send-it",
description = "A demonstration CLI around shipping",
commands = {
CheckAddress.class,
CheckPostcodes.class,
Send.class,
Price.class,
Help.class,
BashCompletion.class
})
public class SendItCli {
}
If you use a different interface then you can simply extend this class and have it implement your interface and call the run()
method from the base class e.g. CustomHelp
@Command(name = "help", description = "Shows help")
public class CustomHelp extends Help<YourInterface> implements YourInterface {
@Override
public void execute() {
super.run();
}
}
If the previous two approaches don’t work then you can always invoke help manually. You can do this in two ways:
Even if you can’t incorporate the Help
command directly you can call its static help methods e.g. Help
@Command(name = "help",
description = "A command that provides help on other commands")
public class Help implements ExampleRunnable {
@AirlineModule
private GlobalMetadata<ExampleRunnable> global;
@Arguments(description = "Provides the name of the commands you want to provide help for")
@BashCompletion(behaviour = CompletionBehaviour.CLI_COMMANDS)
private List<String> commandNames = new ArrayList<String>();
@Option(name = "--include-hidden", description = "When set hidden commands and options are shown in help", hidden = true)
private boolean includeHidden = false;
@Override
public int run() {
try {
com.github.rvesse.airline.help.Help.help(global, commandNames, this.includeHidden);
} catch (IOException e) {
System.err.println("Failed to output help: " + e.getMessage());
e.printStackTrace(System.err);
return 1;
}
return 0;
}
}
Here we call the static help()
method passing in the metadata for our CLI (which Airline populated for us) plus the command names that help was requested for.
Alternatively we can create and use a help generator directly e.g. GenerateHelp
@Command(name = "generate-help")
public class GenerateHelp {
public static void main(String[] args) {
Cli<ExampleRunnable> cli = new Cli<ExampleRunnable>(SendItCli.class);
GlobalUsageGenerator<ExampleRunnable> helpGenerator = new CliGlobalUsageGenerator<>();
try {
helpGenerator.usage(cli.getMetadata(), System.out);
} catch (IOException e) {
e.printStackTrace();
}
}
}
Here we create a specific instance of a GlobalUsageGenerator
and call the usage()
method to generate the help.
So we have seen:
@Option
and @Arguments
@Command
@Cli
This is everything you need to make a functional CLI with Airline.
The user guide which has been linked throughout these slides covers all these topics, plus many more, in lots more detail and examples. Find it at http://rvesse.github.io/airline/
Please try it out, post questions, problems etc. at http://github.com/rvesse/airline/issues
Please feel free to ask questions now!
The remainder of the slides give a quick tour of some of the more advanced features of the library. If we have any extra time left we’ll take a look at these…
As we glossed over earlier we can optionally customise our parser to change the command line behaviour in a variety of ways.
So we saw earlier in our SendItCli
example the parser being customised via the parserConfiguration
field of the
@Cli
annotation. Let’s look more into that now.
@Parser
The @Parser
annotation can be used in two ways:
@Command
, this customises the parser for
SingleCommand
based parsersparserConfiguration
field of the @Cli
annotation, this customises the
parser for Cli
based parsersThere are lots of behaviours that can be customised with this annotation e.g.
By default Airline parses three common option styles in the following order of preference:
StandardOptionParser
- Simple white space separated
option and values e.g. --name value
sets the option --name
to value
LongGetOptParser
- Long form GNU getopt
style e.g.
--name=value
sets the option --name
to value
ClassicGetOptParser
- Short form GNU getopt
style e.g.
-n1
sets the option -n
to 1
This can be customised via several fields of the @Parser
annotation e.g.
parserConfiguration = @Parser(
useDefaultOptionParsers = true,
defaultParsersFirst = false,
optionParsers = { ListValueOptionParser.class }
)
useDefaultOptionParsers
indicates whether to use this default setupdefaultParsersFirst
controls whether the defaults parsers are preferred in favour of any additional ones specifiedoptionParsers
specifies additional option parsers to useA couple of additional styles are built-in but not enabled by default:
MaybePairValueOptionParser
- Arity 2 options where the
user may specify the values as whitespace/=
separated e.g. --name foo bar
and --name foo=bar
are both acceptable
and set the option --name
to the values foo
and bar
ListValueOptionParser
- Options that may be specified
multiple times can be specified in a compact comma separated list form e.g. --name foo,bar
sets the option --name
to the values foo
and bar
.NB - Power Users can also create Custom Option Parsers if desired.
Airline allows for customising how it interprets numeric values passed to any @Option
/@Arguments
annotated field
that has a numeric type i.e. byte
, short
, int
, long
, float
and double
or their boxed equivalents.
This can be controlled either globally on the @Parser
annotation with the numericTypeConverter
field or on a
per-option basis by using the typeConverterProvider
e.g.
@Parser(numericTypeConverter=Hexadecimal.class)
Or:
@Option(name = { "-b", "--bytes"},
description = "Quantity of bytes, optionally expressed in compact form e.g. 1g",
typeConverterProvider = KiloAs1024.class)
@Required
private Long bytes;
Let’s try that:
> ./runExample ByteCalculator --bytes 4gb
4,294,967,296 Bytes
Exiting with Code 0
> ./runExample ByteCalculator --bytes 16k
16,384 Bytes
Exiting with Code 0
Manual pages are provided by using help generator as seen earlier. This is provided in the separate
airline-help-man
module.
If we use ManCommandUsageGenerator
or
ManGlobalUsageGenerator
our output
will be Troff plus man
extensions that can be rendered by the man
command.
For example the Manuals
class demonstrates this:
@Command(name = "generate-manuals", description = "Generates manual pages for this CLI that can be rendered with the man tool")
public class Manuals implements ExampleRunnable {
@AirlineModule
private GlobalMetadata<ExampleRunnable> global;
@Option(name = "--include-hidden", description = "When set hidden commands and options are shown in help", hidden = true)
private boolean includeHidden = false;
@Override
public int run() {
try (FileOutputStream output = new FileOutputStream(this.global.getName() + ".1")) {
new ManGlobalUsageGenerator<ExampleRunnable>(ManSections.GENERAL_COMMANDS).usage(this.global, output);
System.out.println("Generated manuals to " + this.global.getName() + ".1");
} catch (IOException e) {
System.err.println("Error generating completion script: " + e.getMessage());
e.printStackTrace(System.err);
}
return 0;
}
}
Let’s get and view the output:
> ./send-it generate-manuals
Generated manuals to send-it.1
> man ./send-it.1
By the same mechanism we can also do Bash completion, we may need to use the additional
@BashCompletion
annotation on our option/argument fields to control how we’d like Bash to complete things.
We can then create a command like BashCompletions
:
@Command(name = "generate-completions", description = "Generates a Bash completion script, the file can then be sourced to provide completion for this CLI")
public class BashCompletion implements ExampleRunnable {
@AirlineModule
private GlobalMetadata<ExampleRunnable> global;
@Option(name = "--include-hidden", description = "When set hidden commands and options are shown in help", hidden = true)
private boolean includeHidden = false;
@Override
public int run() {
try (FileOutputStream out = new FileOutputStream(this.global.getName() + "-completions.bash")) {
new BashCompletionGenerator<ExampleRunnable>(this.includeHidden, false).usage(global, out);
System.out.println("Generated completion script " + this.global.getName() + "-completions.bash");
} catch (IOException e) {
System.err.println("Error generating completion script: " + e.getMessage());
e.printStackTrace(System.err);
}
return 0;
}
}
Similar to our previous example this creates a script that we can source. Let’s try that out:
> ./send-it generate-completions
Generated completion script send-it-completions.bash
> source send-it-completions.bash
> ./send-it <tab>
check-address check-postcodes generate-completions generate-manuals help price send
There is also a Maven Plugin that can generate the help as part of your build process e.g.
<plugin>
<groupId>com.github.rvesse</groupId>
<artifactId>airline-maven-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<defaultOptions>
<multiFile>true</multiFile>
</defaultOptions>
<formats>
<format>BASH</format>
<format>MAN</format>
</formats>
<sources>
<source>
<classes>
<class>com.github.rvesse.airline.examples.sendit.SendItCli</class>
</classes>
</source>
</sources>
</configuration>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
</plugin>
Added to your pom.xml
would generate both Bash Completion scripts and Manual pages as part of your build.