Cmdliner
cookbookA few recipes and starting blueprints to describe your command lines with Cmdliner
.
Note. Some of the code snippets here assume they are done after:
open Cmdliner
open Cmdliner.Term.Syntax
Command line interfaces are a rather crude and inexpressive user interaction medium. It is tempting to try to be nice to users in various ways but this often backfires in confusing context sensitive behaviours. Here are a few tips and Cmdliner features you should rather not use.
Command groups can have a default command, that is be of the form tool [CMD]
. Except perhaps at the top level of your tool, it's better to avoid them. They increase command line parsing ambiguities.
In particular if the default command has positional arguments, users are forced to use the disambiguation token --
to specify them so that they can be distinguished from command names. For example:
tool -- file …
One thing that is acceptable is to have a default command that simply shows documentation for the group of subcommands as this not interfere with tool operation.
Optional arguments with values can have a default value, that is be of the form --opt[=VALUE]
. In general it is better to avoid them as they lead to context sensitive command lines specifications and surprises when users refine invocations. For examples suppose you have the synopsis
tool --opt[=VALUE] [FILE]
Trying to refine the following invocation to add a FILE
parameter is error prone and painful:
tool --opt
There is more than on way but the easiest here is to specify it as tool --opt -- FILE
. This would have been a careless refinement if --opt
did not have a default option value.
Cmdliner allows to define required optional arguments. Avoid doing this, it's a contradiction in the terms. In command line interfaces optional arguments are defined to be… optional, not doing so is surprising for your users. Use required positional arguments if arguments are required by your command invocation.
Required optional arguments can be useful though if your tool is not meant to be invoked manually and has many required arguments, they become a form of labelled arguments which can clarify invocations in scripts.
If you are porting your command line parsing to Cmdliner
and that you have conventions that clash with Cmdliner
's ones but you need to preserve backward compatibility, one way of proceeding is to pre-process Sys.argv
into a new array of the right shape before giving it to command evaluation functions via the ?argv
optional argument.
These are two common cases:
-warn-error
. In this case simply prefix an additional -
to these arguments when they occur in Sys.argv
before the --
argument; after it, all arguments are positional and to be treated literally.--X
. In this case simply chop the first -
to make it a short option when they occur in Sys.argv
before the --
argument; after it all arguments are positional and to be treated literally.In general Cmdliner wants you to see your tools as regular OCaml functions that you make available to the shell. This means adopting the following source structure:
(* Implementation of your command. Except for exit codes does not deal with
command line interface related matters and is independent from
Cmdliner. *)
let exit_ok = 0
let tool … = …; exit_ok
(* Command line interface. Adds metadata to your [tool] function arguments
so that they can be parsed from the command line and documented. *)
open Cmdliner
open Cmdliner.Term.Syntax
let tool_cmd = … (* Has a term that invokes [tool] *)
let main () = Cmd.eval' tool_cmd
let () = if !Sys.interactive then () else exit (main ())
In particular it is good for your readers' understanding that your program has a single point where it Stdlib.exit
s. This structure is also useful for playing with your program in the OCaml toplevel (REPL), you can invoke its main
function without having the risk of it exit
ing the toplevel.
If your tool named tool
is growing into multiple commands which have a lot of definitions it is advised to:
Tool_cli
.name
in a separate module Cmd_name
which exports its command as a val cmd : int Cmd.t
value.Cmdliner.Cmd.group
in a source file called tool_main.ml
.For an hypothetic tool named tool
with commands import
, serve
and user
, this leads to the following set of files:
cmd_import.ml cmd_serve.ml cmd_user.ml tool_cli.ml tool_main.ml
cmd_import.mli cmd_serve.mli cmd_user.mli tool_cli.mli
The .mli
files simply export commands:
val cmd : int Cmdliner.Cmd.t
And the tool_main.ml
gathers them with a Cmdliner.Cmd.group
:
let tool =
let default = Term.(ret (const (`Help (`Auto, None)))) (* show help *) in
Cmd.group (Cmd.info "tool") ~default @@
[Cmd_import.cmd; Cmd_serve.cmd; Cmd_user.cmd]
let main () = Cmd.value' tool
let () = if !Sys.interactive then () else exit (main ())
By simply using Cmdliner you are already abiding to a great deal of command line interface conventions. Here are a few other ones that are not necessarily enforced by the library but that are good to adopt for your users.
"-"
to specify stdio
in file path argumentsWhenever a command line argument specifies a file path to read or write you should let the user specify -
to denote standard in or standard out, if possible. If you worry about a file sporting this name, note that the user can always specify it using ./-
for the argument.
Very often tools default to stdin
or stdout
when a file input or output is unspecified, here is typical argument definitions to support these conventions:
let infile =
let doc = "$(docv) is the file to read from. Use $(b,-) for $(b,stdin)" in
Arg.(value & opt string "-" & info ["i", "input-file"] ~doc ~docv:"FILE")
let outfile =
let doc = "$(docv) is the file to write to. Use $(b,-) for $(b,stdout)" in
Arg.(value & opt string "-" & info ["o", "output-file"] ~doc ~docv:"FILE")
Here is Stdlib
based code to read to a string a file or standard input if -
is specified:
let read_file file =
let read file ic = try Ok (In_channel.input_all ic) with
| Sys_error e -> Error (Printf.sprintf "%s: %s" file e)
in
let binary_stdin () = In_channel.set_binary_mode In_channel.stdin true in
try match file with
| "-" -> binary_stdin (); read file In_channel.stdin
| file -> In_channel.with_open_bin file (read file)
with Sys_error e -> Error e
Here is Stdlib
based code to write a string to a file or standard output if -
is specified:
let write_file file s =
let write file s oc = try Ok (Out_channel.output_string oc s) with
| Sys_error e -> Error (Printf.sprintf "%s: %s" file e)
in
let binary_stdout () = Out_channel.(set_binary_mode stdout true) in
try match file with
| "-" -> binary_stdout (); write file s Out_channel.stdout
| file -> Out_channel.with_open_bin file (write file s)
with Sys_error e -> Error e
Cmdliner has support to back values defined by arguments with environment variables. The value specified via an environment variable should never take over an argument specified explicitely on the command line. The environment variable should be seen as providing the default value when the argument is absent.
This is exactly what Cmdliner's support for environment variables does, see How can environment variables define defaults?.
Positional arguments are extracted from the command line using these combinators which use zero-based indexing. The following example extracts the first argument and if the argument is absent from the command line it evaluates to "Revolt!"
.
let msg =
let doc = "$(docv) is the message to utter." and docv = "MSG" in
Arg.(value & pos 0 string "Revolt!" & info [] ~doc ~docv)
Optional arguments are extracted from the command line using these combinators. The actual option name is defined in the Cmdliner.Arg.info
structure without dashes. One character strings define short options, others long options (see the parsed syntax).
The following defines the -l
and --loud
options. This is a simple command line argument without a value also known as a command line flag. The term loud
evaluates to false
when the argument is absent on the command line and true
otherwise.
let loud =
let doc = "Say the message loudly." in
Arg.(value & flag & info ["l"; "loud"] ~doc)
The following defines the -m
and --message
options. The term msg
evalutes to "Revolt!"
when the option is absent on the command line.
let msg =
let doc = "$(docv) is the message to utter." and docv = "MSG" in
Arg.(value & opt string "Revolt!" & info ["m"; "message"] ~doc ~docv)
Some of the constraints on the presence of arguments occur when the specification of arguments is converted to terms. The following says that the first positional argument is required:
let msg =
let msg = "$(docv) is the message to utter." and docv = "MSG" in
Arg.(required & pos 0 (some string) None & info [] ~absent ~doc ~docv)
The value msg
ends up being a term of type string
. If the argument is not provided, Cmdliner will automatically bail out during evaluation with an error message.
Note that while it is possible to define required positional argument it is discouraged.
Most positional and optional arguments have a default value. You can use a None
for the default argument and the Cmdliner.Arg.some
or Cmdliner.Arg.some'
combinators on your argument converter which simply wrap its result in a Some
.
let msg =
let msg = "$(docv) is the message to utter." in
let absent = "Random quote." in
Arg.(value & pos 0 (some string) None & info [] ~absent ~doc ~docv:"MSG")
There is more than one way to document the value when it is absent. See How do I document absent argument behaviours?
There are three ways to document the behaviour when an argument is unspecified on the command line.
Cmdliner.Arg.some'
and Cmdliner.Arg.some
there is an optional none
argument that allows you to specify the default value. If you can exhibit this value at definition point use Cmdliner.Arg.some'
, the underlying converter's printer will be used. If not you can specify it as a string rendered in bold via Cmdliner.Arg.some
.~absent
parameter of Cmdliner.Arg.info
. Using this parameter overrides the two previous ways. See this example.As mentioned in Environment variables as default modifiers, any non-required argument can be defined by an environment variable when absent. This works by specifying the env
argument in the argument's Cmdliner.Arg.info
information. For example:
let msg =
let doc = "$(docv) is the message to utter." and docv = "MSG" in
let env = Cmd.Env.info "MESSAGE" in
Arg.(value & pos 0 string "Revolt!" & info [] ~env ~doc ~docv)
When the first positional argument is absent it takes the default value "Revolt!"
, unless the MESSAGE
variable is defined in the environment in which case it takes its value.
Cmdliner handles the environment variable lookup for you. By using the msg
term in your command definition all this gets automatically documented in the tool help.
Environment variable that are used to change argument defaults automatically get documented in a command's man page when you use the argument's term in the command's term.
However if your command implementation looks up other variables and you wish to document them in the command's man page, use the envs
argument of Cmdliner.Cmd.info
.
This documents in the Cmdliner.Manpage.s_environment
manual section of tool
that EDITOR
is looked up to find the tool to invoke to edit the files:
let editor_env = "EDITOR"
let tool … = … Sys.getenv_opt editor_env
let tool_cmd =
let env = Cmd.Env.info editor_env ~doc:"The editor used to edit files." in
Cmd.make (Cmd.info "tool" ~envs:[env]) @@
…
Exit codes are documentd by Cmdliner.Cmd.Exit.info
values and must be given to the command's Cmdliner.Cmd.info
value via the exits
optional arguments. For example:
let conf_not_found = 1
let tool … =
let tool_cmd =
let exits =
Cmd.Exit.info conf_not_found "if no configuration could be found." ::
Cmd.Exit.defaults
in
Cmd.make (Cmd.info "mycmd" ~exits) @@
…
While it is usually not advised to have a default command in a group, just showing docs is acceptable. A term can request Cmdliner's generated help by using Cmdliner.Term.ret
:
let group =
let default = Term.(ret (const (`Help (`Auto, None)))) (* show help *) in
Cmd.group (Cmd.info "group") ~default @@
[first_cmd; second_cmd]
Cmd
evaluation function should I use?There are (too) many command evaluation functions. They have grown organically in a rather ad-hoc manner. Some of these are there for backwards compatibility reasons and advanced usage for complex tools.
Here are the main ones to use and why you may want to use them which essentially depends on how you want to handle errors and exit codes in your tool function.
Cmdliner.Cmd.eval
. This forces your tool function to return ()
. The evaluation function always returns an exit code of 0
unless a command line parsing error occurs.Cmdliner.Cmd.eval'
. Recommended. This forces your tool function to return an exit code exit
which is returned by the evaluation function unless a command line parsing error occurs. This is the recommended function to use as it forces you to think about how to report errors and design useful exit codes for users.Cmdliner.Cmd.eval_result
is akin to Cmdliner.Cmd.eval
except it forces your function to return either Ok ()
or Error msg
. The evaluation function returns with exit code 0
unless Error msg
is computed in which case msg
is printed on the error stream prefixed by the executable name and the evaluation function returns with exit code Cmdliner.Cmd.Exit.some_error
.Cmdliner.Cmd.eval_result'
is akin to Cmdliner.Cmd.eval_result
, except the Ok
case carries an exit code which is returned by the evaluation function.The command line interface manual has all the details and specific instructions for complementing your tool install.
These blueprints when copied to a src.ml
file can be compiled and run with:
ocamlfind ocamlopt -package cmdliner -linkgpkg src.ml
./a.out --help
More concrete examples can be found on the examples page and the tutorial may help too.
These examples follow a conventional Source code structure.
A minimal example.
let tool () = Cmdliner.Cmd.Exit.ok
open Cmdliner
open Cmdliner.Term.Syntax
let tool_cmd =
Cmd.make (Cmd.info "TODO" ~version:"%%VERSION%%") @@
let+ unit = Term.const () in
tool unit
let main () = Cmd.eval' tool_cmd
let () = if !Sys.interactive then () else exit (main ())
This is a tool that has a flag, an optional positional argument for specifying an input file. It also responds to the --version
option.
let exit_todo = 1
let tool ~flag ~infile = exit_todo
open Cmdliner
open Cmdliner.Term.Syntax
let flag = Arg.(value & flag & info ["flag"] ~doc:"The flag")
let infile =
let doc = "$(docv) is the input file. Use $(b,-) for $(b,stdin)." in
Arg.(value & pos 0 string "-" & info [] ~doc ~docv:"FILE")
let tool_cmd =
let doc = "The tool synopsis is TODO" in
let man = [
`S Manpage.s_description;
`P "$(iname) does TODO" ]
in
let exits =
Cmd.Exit.info exit_todo ~doc:"When there is stuff todo" ::
Cmd.Exit.defaults
in
Cmd.make (Cmd.info "TODO" ~version:"%%VERSION%%" ~doc ~man ~exits) @@
let+ flag and+ infile in
tool ~flag ~infile
let main () = Cmd.eval' tool_cmd
let () = if !Sys.interactive then () else exit (main ())
This is a tool with two subcommands hey
and ho
. If your tools grows many subcommands you may want to follow these source code conventions.
let hey () = Cmdliner.Cmd.Exit.ok
let ho () = Cmdliner.Cmd.Exit.ok
open Cmdliner
open Cmdliner.Term.Syntax
let flag = Arg.(value & flag & info ["flag"] ~doc:"The flag")
let infile =
let doc = "$(docv) is the input file. Use $(b,-) for $(b,stdin)." in
Arg.(value & pos 0 file "-" & info [] ~doc ~docv:"FILE")
let hey_cmd =
let doc = "The hey command synopsis is TODO" in
Cmd.make (Cmd.info "hey" ~doc) @@
let+ unit = Term.const () in
ho ()
let ho_cmd =
let doc = "The ho command synopsis is TODO" in
Cmd.make (Cmd.info "ho" ~doc) @@
let+ unit = Term.const () in
ho unit
let tool_cmd =
let doc = "The tool synopsis is TODO" in
Cmd.group (Cmd.info "TODO" ~version:"%%VERSION%%" ~doc) @@
[hey_cmd; ho_cmd]
let main () = Cmd.eval' tool_cmd
let () = if !Sys.interactive then () else exit (main ())