Skip to content

A highly flexible Crystal CLI builder in the style of OptionParser.

License

Notifications You must be signed in to change notification settings

shinzlet/phreak

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

54 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PSA

Since this library was written, RX14 and others have integrated many of Phreak's features into OptionParser, the CLI builder in the standard library. I'm thrilled to see that these changes have been made, but Phreak now brings much less to the table than it did in it's hayday.

So, before using Phreak, please check out OptionParser's updated feature set - you likely don't need a library at all!

Phreak

Crystal CI

Phreak is a CLI builder in the style of Crystal's builtin OptionParser. It aims to provide greater flexibility by integrating subcommands natively, all while retaining a very simple callback based code style.

If you use Phreak in a project, please let me know! I'd love to see what you've made, and would be happy to put your project in the examples section. (My email address is in my github profile.)

Table of contents

Features

Much like OptionParser, Phreak makes registering commands incredibly easy:

Basic commands

require "phreak"

Phreak.parse! do
  # This will respond to the arguments "-a" or "--flag1"
  root.bind(short_flag: 'a', long_flag: "flag1") do
    puts "Someone called?"
  end
end

Automatic Documentation

You can also attach a description to a command to easily print a help menu. These are optional, but documenting a CLI as you work is rather convenient.

require "phreak"

Phreak.parse! do |root|
  # Sets the helpmenu banner for `root`
  root.banner = "A cli."

  root.bind(short_flag: 'd', long_flag: "do-nothing",
            description: "Has no effect.") do
  end

  root.bind(short_flag: 'h', long_flag: "help",
            description: "Prints a help menu.") do
    # Printing a `Subparser` (the objects that Phreak yields) will
    # display a formatted help menu showing the commands bound to that
    # object and their descriptions. In this case, as `root` is bound to
    # two things, both will be printed with invocation instructions and
    # descriptions.
    puts root
  end
end

The block above, when invoked cli -h, will print the following menu:

A cli.

Commands:
    --do-nothing, -d          Has no effect.
    --help, -h                Prints a help menu.

There are some more advanced formatting tricks that can be used, here - see advanced help menus for more information. Also, for more information about how to use these menus in a more complex way, see nested subcommands.

The above two features capture most of the scope of OptionParser. However, you're probably here for more extensive functionality! Let's get to the good stuff.

Subcommands

Phreak allows you to create clear, human-readable CLIs with ease, thanks to subcommands.

require "phreak"

Phreak.parse! do |root|
  root.bind(word: "throw") do |sub|
    # Responds to "./binary throw -p" or "subcommand throw --party"
    sub.bind(short_flag: 'p', long_flag: "party") do
      puts "Whoo!"
    end
  end

  root.bind(word: "info") do |sub|
    # Responds to "./binary info -d" or "subcommand info --dogs"
    sub.bind(short_flag: 'd', long_flag: "dogs") do
      puts "dogs are just incredible."
    end
  end
end

Nested subcommands

Building on that more fluent subcommand syntax mentioned above, Phreak also lets you create heirarchical commands (think nmcli device wifi connect ..., for example).

require "phreak"

Phreak.parse! do |root|
  root.bind(word: "wifi", description: "Configure or control wireless connections.") do |wifi|
    wifi.banner = "Manage wireless connections. Invocation: cli wifi [subcommand]"

    wifi.bind(word: "status", description: "Get the current status of the modem.") do
      # Reponds to "nested wifi status"
    end

    wifi.bind(word: "set", description: "Set the the wifi state.") do |set|
      # Responds to "nested wifi set"
    end

    wifi.bind(word: "help",
              description: "display specific help about the `wifi` subcommand.") do |help|
      puts wifi
    end
  end

  root.bind(word: "help", description: "display this help menu.") do |help|
    puts root
  end
end

This command also demonstrates how to use documentation with nested commands! Each subcommand has a help command defined on it, which allows invocation like ./binary help for general help, or ./binary wifi help for more specific information!

Command types

Phreak allows you to create three primary types of commands:

  • Short flags (e.g. ls -a)
  • Long flags (e.g. ls --all)
  • Words (e.g. git push)

You can alias one of each to a command in one line, too:

require "phreak"

Phreak.parse! do |root|
  root.bind(short_flag: 'a', long_flag: "all", word: "all") do
    # Responds to -a, --all, or 'all'
  end
end

Fuzzy matching

Phreak allows you to fuzzy match commands as desired! For example:

require "phreak"

Phreak.parse! do |root|
  root.fuzzy_bind(word: "enable") do |sub, match|
    # Responds to words close to enable - enab, enablt, for example
    puts "Fuzzy matched #{match}"
  end
end

Compound flags

Many CLIs allow flags to be stacked to run several processes in a row. For example, ls -al tells ls to print all files in a long format.

require "phreak"

Phreak.parse! do |root|
  root.bind(short_flag: 'a') do
    puts "A!"
  end

  root.bind(short_flag: 'b') do
    puts "B!"
  end

  root.bind(short_flag: 'c') do
    puts "C!"
  end
end

Given the above program, compiled to binary:

./binary -abc     # Prints "A!B!C!"
./binary -ac      # Prints "A!C!"
./binary -bbb     # Prints "B!B!B!"

Default actions

In some cases, CLIs should have a default behaviour only when no arguments are provided. Phreak makes that easy:

require "phreak"

Phreak.parse! do |root|
  root.default do
    puts "no arguments provided"
  end
end

Basic error handling

Phreak makes it easy to detect incorrect usage of your CLI.

require "phreak"

Phreak.parse! do |root|
  root.bind(word: "say") do |sub|
    sub.bind(word: "hi") do
      puts "Hi!"
    end
  end

  root.default do
    puts "No arguments provided"
  end

  root.missing_args do |apex|
    puts "Missing an argument after #{apex}"
  end

  root.unrecognized_args do |arg|
    puts "Unrecognized argument: #{arg}"
  end
end

Here, if we run ./binary say, the missing_args handler will be called. If we ran ./binary say goodbye, the unrecognized_args handler would be run. If we don't provide an argument at all, the default handler is called. Finally, if we correctly invoke ./binary say hi, the CLI will print out "Hi!"

Advanced Help Menus

Help menus can be tweaked in many ways. All the configuration is done on a per-subparser basis, which allows help menus to be tailored to subcommands. As described before, subparsers can be given a custom command title with the banner property:

subparser.banner = "this is a command"

Indentation, too, can be configured. Below is a snippet of Subparser.cr showing the relevant properties:

# An optional banner to display in the autogenerated help menu.
property banner : String | Nil

# The left indentation unit used in printing default help menus.
property help_indent : String = " " * 4

# The minumum width (word, long flag, and spaces) of the command section of
# the default help menu.
property command_section_width : Int8 = 30

# If the command section exceeds the width defined above, the fallback
# padding will be added to ensure there is at least some separation
# between command and description.
property fallback_description_padding : String = " " * 4

Error bubbling

Due to the way that Phreak works, you can actually bind a missing_args or unrecognized_args handler to any part of a nested command. For example:

require "phreak"

Phreak.parse! do |root|
  root.bind(word: "say") do |sub|
    sub.bind(word: "hi") do
      puts "Hi!"
    end

    sub.bind(word: "hello") do |sub|
      sub.bind(word: "tomorrow") do
        puts "Sure thing!"
      end

      sub.missing_args do
        puts "Hello!"
      end

      sub.unrecognized_args do |arg|
        puts "When's #{arg}?"
      end
    end

    sub.unrecognized_args do |arg|
      puts "I can't say #{arg}!"
    end
  end
end

Let's see what happens in a few example inputs:

./binary say hi

  • The binding for 'say' is recognized, and a Subparser (the sub variable) is created.
  • The binding for 'hi' is invoked, and the program terminates.

./binary say goodbye

  • The binding for 'say' is recognized.
  • None of the bindings match 'goodbye', so the unrecognized_args handler on say is invoked.

./binary say hello

  • The binding for 'say' is recognized.
  • The binding for 'hello' is recognized.
  • We tell the Subparser to bind the word 'tomorrow' to a callback, but we're out of keywords!
  • The missing_args handler is invoked in the scope of the 'hello' binding.
  • The handler says hello!

The lattermost case may seem very similar to the default handler, but there are some important differences. default is only ever called if the size of the arguments array is zero (there are absolutely no arguments). It can also only be bound to the root Parser (which is actuallly a Subparser+).

Errors also bubble - that is, if we had not defined a missing_args handler on 'hello', Phreak would then try to invoke a missing_args event on 'say', then on the root subparser. If no handlers are found on the traversal back up, an exception is raised.

Reusable parsers

What we've seen above is incredibly useful for non-interactive CLIs, but it isn't always the case that you want to throw away your parser after the first use. Here's an example of how you can make an interactive CLI with Phreak:

require "phreak"

# Ignore this variable for now - it is specific to this example.
running = true

# This doesn't actually run the parser at all -
# it just returns a convenient handle to it.
parser = Phreak.create_parser do |root|
  root.bind(word: "create") do |sub|
    sub.grab do |sub, name|
      puts "Creating partition #{name}"
    end
  end

  root.bind(word: "quit") do
    puts "Exiting."
    running = false
  end
end

puts "Disk formatting utility"

# Here's where we will actually run the parser!
while running
  # Print a prompt, then read the next line.
  printf ">> "
  next_line = gets || ""

  parser.parse(next_line.split(" "))
end

This example is quite different than the others, but it showcases some fascinating things. We are creating a reusable parser using Phreak.create_parser, and creating our bindings once. Then, later in the program, we are repeatedly calling that parser on the user's input. When the user enters the 'quit' command, the matching endpoint is run in the parser, which sets running to be false, thus terminating the loop.

Here is some example output:

Disk formatting utility
>> create p1    
Creating partition p1
>> foobar
Unrecognized command foobar.
>> create
The command create requires more parameters.
>> quit
Exiting.

Planned features

  • Autogenerated documentation (at compile time)
  • Autogenerated fish completions
  • Typo fix suggestions (did you mean 'commit'?)

Check out the project Trello for more.

Installation

  1. Add phreak to your shard.yml:

    dependencies:
      phreak:
        github: shinzlet/phreak
  2. Run shards install

Examples

I wrote sd using Phreak! It was a great case study of what features I needed to add to get a truly flexible CLI builder.

Development

Please see phreak's vivisection for an overview of how Phreak works under the hood!

Contributing

  1. Fork it (https://github.com/your-github-user/./fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

About

A highly flexible Crystal CLI builder in the style of OptionParser.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published