Write Large CLIs Easily
(require natural-cli) | package: natural-cli |
This collection helps you write non-trivial command-line interfaces (CLIs).
If you want to put together a simple CLI or offer a single level of subcommands, then don’t use this. Just use racket/cmdline.
Use natural-cli if you have subcommands that have subcommands that have subcommands, and are constantly iterating on the interface’s design. In this situation, the CLI’s structure is a distraction and you’ll need help managing it.
1 Quick Start
After installing, run the following:
$ mkdir cli && cd cli |
$ natural-cli mkmodule olympian.rkt |
$ racket olympian.rkt |
You just made a command module that supports subcommands (See Command Modules for details).
To add a subcommand, make a new module with an underscore separating the command name from the subcommand name.
$ natural-cli mkmodule olympian_jump.rkt |
The code duplication you’ll observe is intentional. In practice, each module needs to evolve independently.
If you run olympian.rkt again, you’ll see jump as a subcommand.
You can keep adding deeper subcommands.
$ natural-cli mkmodule olympian_jump_high.rkt olympian_jump_far.rkt |
If your shell supports brace expansion, you can express command trees succiently. You do not have to run this command. It’s only here as a quick tip.
$ natural-cli mkmodule olympian_run{_{fast,slow},}.rkt |
For polish, you can create a Racket launcher using the mklauncher command. If you want a GRacket launcher, use the -g switch.
$ natural-cli mklauncher olympian.rkt |
$ ./olympian |
$ ./olympian jump |
$ ./olympian jump high |
$ ./olympian jump far |
Each module created using natural-cli mkmodule is eligible for use in racket-launcher-libraries or gracket-launcher-libraries in info.rkt.
To recap on what we observed:
Underscores separate parent commands from subcommands.
These modules must all be .rkt files sitting in the same directory.
Every possible command in the command tree must have a module.
2 Flag Handling
natural-cli scopes flags to their associated commands. This allows you to pass flags between commands to configure behavior at different levels of a program.
$ ./olympian --home Germany jump far --start-distance 20m |
This means that a flag’s position matters between commands and subcommands. Here we cover the implications.
2.1 Getting Help
racket/cmdline reserves the -h flag for requesting help, and normally exits after doing so.
You can request help for different commands by moving the -h flag.
$ ./olympian jump high -h |
$ ./olympian jump -h high |
$ ./olympian -h jump high |
The exit behavior prevents any subcommands from running, so any additional -h or subcommand following the first -h will have no effect.
You can configure the behavior of -h using #:handlers in command-line in the associated module.
2.2 Example: Illustrating Command Scope
To show how flags are scoped to subsets of a command line, we’ll review how two flags of the same name are subject to the rules of different parsers.
Here’s a config.rkt that holds dynamic runtime data shared between commands.
"config.rkt"
#lang racket/base (provide (all-defined-out)) (define top (make-parameter 0)) (define sub (make-parameter 0))
Next, let’s run natural-cli mkmodule top.rkt top_sub.rkt and edit each new file as follows:
"top.rkt"
#lang racket/base (provide process-command-line) (require "./config.rkt") (module+ main (void (process-command-line))) (require racket/cmdline racket/runtime-path (only-in mzlib/etc this-expression-file-name) natural-cli) (define program-name (get-program-name (this-expression-file-name))) (define-runtime-path cli-directory ".") (define (process-command-line) (define-values (fin arg-strs help unknown) (make-subcommand-handlers cli-directory program-name)) (command-line #:program program-name #:multi [("-a") "Increment top" (top (add1 (top)))] #:handlers fin arg-strs help unknown))
"sub.rkt"
#lang racket/base (provide process-command-line summary) (require "./config.rkt") (define summary "Prints counters.") (require racket/cmdline racket/runtime-path (only-in mzlib/etc this-expression-file-name) natural-cli) (define program-name (get-program-name (this-expression-file-name))) (define-runtime-path cli-directory ".") (define (process-command-line) (command-line #:program program-name #:once-each [("-a") "Increment sub" (sub (add1 (sub)))] #:args _ (printf "~a ~a~n" (top) (sub))))
Notice that top allows multiple uses of -a with #:multi, and sub uses #:once-each.
For this collection, this session holds:
$ ./top sub -a |
0 1 |
$ ./top -a sub |
1 0 |
$ ./top -a sub -a |
1 1 |
$ ./top -a -a sub -a |
2 1 |
$ ./top -a sub -a -a |
top_sub: the -a option can only be specified once |
3 Tweaking the CLI
The mkmodule command writes code that is coupled to the file system. So long as that code is preserved, you can design a CLI by changing files.
To remove a subcommand from a project, just delete its file.
$ rm olympian_jump_high.rkt |
To add a command, run natural-cli mkmodule as discussed or copy an existing module for later editing.
$ natural-cli mkmodule olympian_throw.rkt # or... |
$ cp olympian_jump.rkt olympian_throw.rkt |
To rename a command, rename the file.
$ mv olympian_swim.rkt olympian_dive.rkt |
To move a command under a different parent, rename the file.
$ mv olympian_jump_far.rkt olympian_throw_far.rkt |
For more advanced cases, use batch renaming.
4 Command Modules
Each Racket module managed by natural-cli should (provide process-command-line summary).
value
summary : string?
If not provided, this will default to "Run with -h for details."
procedure
(process-command-line) → any/c
If not provided, this will default to a procedure that announces a missing implementation and evaluates (exit 1).
5 API Reference
(require natural-cli) offers bindings that cooperate with code created by natural-cli mkmodule.
procedure
(make-subcommand-handlers cli-directory program-name)
→
((list?) () #:rest list? . ->* . any) (listof string?) (string? . -> . any) (string? . -> . any) cli-directory : directory-exists? program-name : string?
finish-expr dynamically instantiates the command module referenced by the first positional (non-flag) argument from the command line.
procedure
(get-program-name source-file) → string?
source-file : path-string?