kebab

Declarative, type-safe command line parsing for Crystal

Kebab

WIP. Public API is subject to change.

Installation

Add the dependency to your shard.yml:

dependencies:
  kebab:
    github: DanielGilchrist/kebab

Then run shards install.

Quick example

Annotate a struct, then parse ARGV into it:

require "kebab"

@[Kebab::Command(summary: "Greet someone")]
struct Greet
  include Kebab::Parseable

  @[Kebab::Argument(description: "Name to greet")]
  getter name : String

  @[Kebab::Option(short: 'l', description: "Make it loud")]
  getter? loud : Bool = false
end

# Greet.parse : Greet | Kebab::Help | Kebab::Errors  (args default to ARGV)
case result = Greet.parse
in Greet
  message = "Hello, #{result.name}!"
  message = message.upcase if result.loud?
  puts message
in Kebab::Help
  puts result        # the user passed --help
in Kebab::Errors
  STDERR.puts result # parsing failed
  exit(1)
end

parse never raises. It returns one of exactly three things: the parsed struct, a Kebab::Help when --help was asked for, or a Kebab::Errors when the input was invalid. Crystal's case ... in makes you handle all three.

Commands that carry behaviour

When a command should own its logic, give it a def run and dispatch with Type.run. It parses, calls run on success, prints help to stdout and errors to stderr, and returns a Bool:

@[Kebab::Command(summary: "Greet someone")]
struct Greet
  include Kebab::Parseable

  @[Kebab::Argument(description: "Name to greet")]
  getter name : String

  @[Kebab::Option(short: 'l', description: "Make it loud")]
  getter? loud : Bool = false

  def run : Nil
    message = "Hello, #{name}!"
    message = message.upcase if loud?
    puts message
  end
end

exit(1) unless Greet.run(ARGV)

Field types and conversion

A field's type sets how its value parses:

  • String and the number types (Int32, Float64, and the other int/float widths) are built in.
  • Bool is a flag, never a value.
  • Enums parse automatically. Matching is case-insensitive and treats - and _ the same. An unknown value errors with the valid names.
  • Array(T) collects the remaining positionals, each parsed as T.

Other types need a converter:, a type or module with self.convert(input : String) : T | Kebab::Convert::Failure:

struct Duration
  getter minutes : Int32

  def initialize(@minutes : Int32)
  end

  def self.convert(input : String) : Duration | Kebab::Convert::Failure
    minutes = input.to_i?
    return Kebab::Convert.failure("expected a number of minutes") unless minutes
    new(minutes)
  end
end

@[Kebab::Option(converter: Duration)]
getter pause : Duration?

An unsupported type with no converter: is a compile error.

Examples

Runnable walkthroughs in examples/:

Global options

By default an option is only recognised before its command's subcommand (app --verbose start, not app start --verbose). Mark it global: true to accept it anywhere in that command's portion of the line, including after subcommands:

struct App
  include Kebab::Parseable

  @[Kebab::Option(global: true)]
  getter? no_colour : Bool = false

  @[Kebab::Subcommand]
  getter command : Start | Finish
end

# all set no_colour? on the App instance:
App.parse(["--no-colour", "start"])
App.parse(["start", "--no-colour"])

The value lives on the declaring command's instance, so in command mode read it there and thread it into the subcommand's run like any other dependency. A global is also listed in its subcommands' help and completion, since it's usable there too.

Collection stops at --. A global is recognised throughout its declaring command's subtree, so a descendant can't reuse its name or short letter. That's a compile error, not a silent shadow. See examples/global/.

Shell completion

Kebab::Completion::Shell generates completion scripts for fish, bash, and zsh. Expose it as a subcommand with a typed shell argument:

@[Kebab::Command(summary: "Print a shell completion script")]
struct Completions
  include Kebab::Parseable

  @[Kebab::Argument]
  getter shell : Kebab::Completion::Shell

  def run : Nil
    puts shell.generate(Todo.schema)
  end
end

An unknown shell is a parse error listing the valid ones. Source the script at shell startup so it tracks the current binary:

todo completions fish | source          # fish
eval "$(todo completions bash)"         # bash, in ~/.bashrc
source <(todo completions zsh)          # zsh, in ~/.zshrc after compinit

A completion script is built from Type.schema, so for any other shell you generate the script yourself. See examples/completions/.

Command structure

Type.schema returns the command and its subcommands as a Kebab::Schema::Command (options, arguments, subcommands, usage). It drives help and completion, is carried on every parse error as error.schema, and you can walk it for your own tooling.

Colour

Help and error output is colourised through Crystal's Colorize, which disables itself when the output is not a TTY. To force it off (for example in a host program that handles its own colour), set Colorize.enabled = false before calling parse or run.

API docs

Generate them with crystal docs from the repo root.

Repository

kebab

Owner
Statistic
  • 0
  • 0
  • 1
  • 3
  • 1
  • about 11 hours ago
  • June 14, 2026
License

MIT License

Links
Synced at

Sat, 04 Jul 2026 21:26:26 GMT

Languages