Skip to content

Commit

Permalink
🕹️
Browse files Browse the repository at this point in the history
  • Loading branch information
m-o-e committed Jan 4, 2022
0 parents commit ae4d82b
Show file tree
Hide file tree
Showing 13 changed files with 506 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .ameba.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Metrics/CyclomaticComplexity:
Description: Disallows methods with a cyclomatic complexity higher than `MaxComplexity`
MaxComplexity: 10
Excluded:
- src/cliq.cr
Enabled: true
Severity: Convention
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
17 changes: 17 additions & 0 deletions .github/workflows/crystal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Build

on: [push]

jobs:
build:
runs-on: ubuntu-latest

container:
image: crystallang/crystal:1.2.2

steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: shards install
- name: Run tests
run: make ci
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/lib/
/bin/
/.shards/
*.dwarf
.DS_Store

# Libraries don't need dependency lock
# Dependencies will be locked in applications that use them
/shard.lock
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2022 moe <[email protected]>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
14 changes: 14 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ci: test

test: lint
@crystal spec

lint: bin/ameba
@bin/ameba

install:
shards

bin/ameba:
@make install

142 changes: 142 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Cliq [![Build](https://github.com/busyloop/cliq/workflows/Build/badge.svg)](https://github.com/busyloop/cliq/actions?query=workflow%3ABuild+branch%3Amaster) [![GitHub](https://img.shields.io/github/license/busyloop/cliq)](https://en.wikipedia.org/wiki/MIT_License) [![GitHub release](https://img.shields.io/github/release/busyloop/cliq.svg)](https://github.com/busyloop/cliq/releases)

The quick way to create a user-friendly **Command Line Interface** in Crystal. ⚡powered by [Toːka](https://github.com/Papierkorb/toka)

![](./examples/demo.gif)


## Features

* Easily create a CLI with nested sub-commands and complex flags
* Automatic help screens, argument type coercion, input validation and meaningful error messages



## Installation

1. Add the dependency to your `shard.yml`:

```yaml
dependencies:
cliq:
github: busyloop/cliq
```
2. Run `shards install`



## Basic Usage

```crystal
require "cliq"
class GreetPerson < Cliq::Command
# Declare the command name, description and help-text(s) for positional arguments
command "greet person", "Greet someone", ["<name> Name to greet"]
# Declare the flags for this command
flags({
yell: Bool?,
count: {
type: Int32,
default: 1,
value_name: "TIMES",
description: "Print the greeting this many times"
}
})
def call(args)
raise Cliq::Error.new("Must provide a <name>") if args.size < 1
greeting = "Hello #{args[0]}!"
greeting = greeting.upcase if @yell
@count.times { puts greeting }
end
end
# Let's go!
Cliq.invoke(ARGV)
```



## How it works

* You can have any number of `Cliq::Command` subclasses in your program.
Cliq merges them together to form the final CLI.
* Each must have a method `#call(args : Array(String))`.
* Use the `command`-macro to declare the _command name_, _description_ and _description of positional arguments_
* The latter two are optional.
* Spaces are allowed in the _command name_.
If you want a sub-command `foo bar batz` then just put exactly that in there.
* Use the `flags`-macro to declare the flags that your command accepts

* See [examples/demo.cr](./examples/demo.cr) for a demo with multiple sub-commands



## The `flags`-macro

### Short-hand syntax

```crystal
flags({
verbose: Bool?,
count: Int32
})
```

This allows `--verbose` or `-v` (optional)
and requires `--count N` or `-c N` (where N must be an integer).

### Long-hand syntax

```crystal
flags({
verbose: {
type: Bool,
nilable: true,
description: "Enable verbosity"
}
count: {
type: Int32,
description: "Print the greeting this many times",
verifier: ->(n : Int32){ n >= 0 || "Must be greater than zero" }
}
})
```

#### Reference

- `type` The type. Examples: `String`, `Int32?`, `Array(String)`
- `nilable` If the type is optional ("nil-able"). You can also make the `type` nilable for the same effect.
- `default` The default value.
- `description` The human-readable description. Can be multi-line.
- `long` Allows to manually configure long-options. Auto-generated from the name otherwise.
- `short` Allows to manually configure short-options. Auto-generated otherwise. Set to `false` to disable.
- `value_name` Human-readable value name, shown next to the option name like: `--foo=HERE`
- `category` Human-readable category name, for grouping in the help page. Optional.
- `value_converter` [Converter](https://github.com/Papierkorb/toka#converters) to use for the value.
- `key_converter` [Converter](https://github.com/Papierkorb/toka#converters) for the key to use for a `Hash` type.
- `verifier` [Verifier](https://github.com/Papierkorb/toka#input-verification) to validate the value.



## Credits

Cliq is a thin wrapper around [Toːka](https://github.com/Papierkorb/toka).
Please refer to the [Toːka documentation](https://github.com/Papierkorb/toka#advanced-usage) for advanced usage.

If you do not need sub-commands in your program
then you should consider using Toːka directly.



## Contributing

1. Fork it (<https://github.com/busyloop/cliq/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

62 changes: 62 additions & 0 deletions examples/demo.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
require "../src/cliq"

class GreetWorld < Cliq::Command
# `flags` declares the flags that
# this command will recognize.
#
# We'll keep it simple for this first example
# and only have a mandatory Int32 option
# called "--count".
flags({
count: Int32
})

# `command` registers this class with Cliq.
# It takes up to 3 arguments:
#
# 1. Command name (required, may contain spaces)
# 2. Description (optional, shown on help screen)
# 3. Array of positional arguments (optional, shown on help screen)
#
# We use only the first two here because our command
# doesn't take any positional arguments.
command "greet world", "Greet the world"

# `#call` gets called when your command is invoked.
#
# It's the only mandatory method that your Cliq::Command
# subclass must have. Here you can see how to access the
# option values (`@count`) and positional args (`args`).
def call(args)
@count.times do
puts "Hello world!"
end
end
end

class GreetPerson < Cliq::Command
# See https://github.com/Papierkorb/toka#advanced-usage
flags({
yell: Bool?,
count: {
type: Int32,
default: 1,
value_name: "TIMES",
description: "Print the greeting this many times (default: 1)"
}
})

command "greet person", "Greet someone", ["<name> Name to greet"]

def call(args)
raise Cliq::Error.new("Missing argument: <name>") if args.size < 1
greeting = "Hello #{args[0]}!"
greeting = greeting.upcase if @yell
@count.times do
puts greeting
end
end
end

# Let's go!
Cliq.invoke(ARGV) unless ENV["ENV"]? == "test"
Binary file added examples/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions shard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: cliq
version: 1.0.0

authors:
- moe <[email protected]>

crystal: 1.2.2

description: |
CLI Framework
license: MIT

dependencies:
toka:
github: Papierkorb/toka

development_dependencies:
stdio:
github: mosop/stdio

ameba:
github: crystal-ameba/ameba
79 changes: 79 additions & 0 deletions spec/cliq_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
require "./spec_helper"
require "../examples/demo"

describe GreetWorld do
it "says Hello world twice when --count 2" do
stdout, _ = Stdio.capture do |io|
Cliq.invoke(["greet", "world", "--count", "2"])
[io.out.gets_to_end, io.err.gets_to_end]
end

stdout.should_not be_nil
stdout.not_nil!.should eq("Hello world!\nHello world!\n")
end

it "says Hello world once when --count 1" do
stdout, _ = Stdio.capture do |io|
Cliq.invoke(["greet", "world", "--count", "1"])
[io.out.gets_to_end, io.err.gets_to_end]
end

stdout.should_not be_nil
stdout.not_nil!.should eq("Hello world!\n")
end

it "shows help on missing param" do
stdout, _ = Stdio.capture do |io|
Cliq.invoke(["greet", "world"])
[io.out.gets_to_end, io.err.gets_to_end]
end
stdout.not_nil!.should contain("Missing option \"count\"")
end
end

describe GreetPerson do
it "shows help on missing param" do
stdout, _ = Stdio.capture do |io|
Cliq.invoke(["greet", "person"])
[io.out.gets_to_end, io.err.gets_to_end]
end
stdout.not_nil!.should contain("Usage:")
stdout.not_nil!.should contain("yell")
stdout.not_nil!.should contain("Missing argument: <name>")
end
end

describe Cliq do
it "shows command list when no valid command is given" do
stdout, _ = Stdio.capture do |io|
Cliq.invoke(["derp"])
[io.out.gets_to_end, io.err.gets_to_end]
end
stdout.not_nil!.should contain("Usage:")
stdout.not_nil!.should contain("Greet someone")
stdout.not_nil!.should contain("Greet the world")
end

it "shows command list when no command is given" do
stdout, _ = Stdio.capture do |io|
Cliq.invoke([] of String)
[io.out.gets_to_end, io.err.gets_to_end]
end
stdout.not_nil!.should contain("Usage:")
stdout.not_nil!.should contain("Greet someone")
end

# Can't easily test this because
# Toka exits after displaying help.
#
# We'll just trust Toka's own tests for now...

# it "shows command help when asked for it" do
# stdout, stderr = Stdio.capture do |io|
# Cliq.invoke(["greet", "--help"])
# [io.out.gets_to_end, io.err.gets]
# end
# stdout.not_nil!.should contain("Usage:")
# ...
# end
end
5 changes: 5 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ENV["ENV"] = "test"

require "spec"
require "stdio"
require "../src/cliq"
Loading

0 comments on commit ae4d82b

Please sign in to comment.