Jargon

Define your CLI jargon with JSON Schema.

A Crystal library that generates CLI interfaces from JSON Schema definitions. Define your data structure once in JSON Schema, get a CLI parser with validation for free.

Features

Installation

Add the dependency to your shard.yml:

dependencies:
  jargon:
    github: trans/jargon

Then run shards install.

Usage

require "jargon"

# Define your schema
schema = %({
  "type": "object",
  "properties": {
    "name": {"type": "string", "description": "User name"},
    "age": {"type": "integer"},
    "verbose": {"type": "boolean"}
  },
  "required": ["name"]
})

# Create CLI and run
cli = Jargon.cli("myapp", json: schema)
cli.run do |result|
  puts result.to_pretty_json
end

The run method automatically handles:

YAML Schemas

YAML schemas are supported directly:

# schema.yaml
type: object
properties:
  name:
    type: string
    description: User name
  verbose:
    type: boolean
    short: v
required:
  - name
schema = File.read("schema.yaml")
cli = Jargon.cli("myapp", yaml: schema)

Argument Styles

Three styles are supported interchangeably:

# Equals style (minimal)
myapp name=John age=30 verbose=true

# Colon style
myapp name:John age:30 verbose:true

# Traditional style
myapp --name John --age 30 --verbose

Mix and match as you like:

myapp name=John --age 30 verbose:true

Nested Objects

Use dot notation for nested properties:

schema = %({
  "type": "object",
  "properties": {
    "user": {
      "type": "object",
      "properties": {
        "name": {"type": "string"},
        "email": {"type": "string"}
      }
    }
  }
})

cli = Jargon.cli("myapp", json: schema)
result = cli.parse(["user.name=John", "[email protected]"])
# => {"user": {"name": "John", "email": "[email protected]"}}

Supported Types

| JSON Schema Type | CLI Example | Notes | |------------------|-------------|-------| | string | name=John | Default type | | integer | count=42 | Parsed as Int64, strict validation | | number | rate=3.14 | Parsed as Float64, strict validation | | boolean | verbose=true or --verbose | Flag style supported | | array | tags=a,b,c | Comma-separated | | object | user.name=John | Dot notation |

Validation Constraints

Standard JSON Schema validation keywords are supported:

{
  "properties": {
    "port": {"type": "integer", "minimum": 1, "maximum": 65535},
    "ratio": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1},
    "password": {"type": "string", "minLength": 8, "maxLength": 64},
    "email": {"type": "string", "format": "email"},
    "website": {"type": "string", "format": "uri"},
    "level": {"type": "string", "enum": ["debug", "info", "warn", "error"]},
    "files": {"type": "array", "minItems": 1, "maxItems": 10, "uniqueItems": true},
    "apiVersion": {"type": "string", "const": "v1"},
    "tags": {
      "type": "array",
      "items": {"type": "string", "enum": ["alpha", "beta", "stable"]}
    }
  }
}

Boolean Flags

Boolean flags support multiple styles:

# Flag style (sets to true)
myapp --verbose

# Explicit value
myapp --verbose true
myapp --verbose false
myapp --enabled no

# Equals style
myapp verbose=true
myapp --verbose=false

Recognized boolean values: true/false, yes/no, on/off, 1/0 (case-insensitive).

When a boolean flag is followed by a non-boolean value, the value is not consumed:

# --verbose is true, output.txt is a positional arg
myapp --verbose output.txt

Strict Numeric Validation

Invalid numeric values produce clear error messages:

$ myapp --count abc
Error: Invalid integer value 'abc' for count

$ myapp --count 10x
Error: Invalid integer value '10x' for count

Typo Suggestions

Mistyped options get helpful "did you mean?" suggestions:

$ myapp --verbos
Error: Unknown option '--verbos'. Did you mean '--verbose'?

$ myapp --formt json
Error: Unknown option '--formt'. Did you mean '--format'?

Positional Arguments

Define positional arguments with the positional array:

schema = %({
  "type": "object",
  "positional": ["file", "output"],
  "properties": {
    "file": {"type": "string", "description": "Input file"},
    "output": {"type": "string", "description": "Output file"},
    "verbose": {"type": "boolean"}
  },
  "required": ["file"]
})

cli = Jargon.cli("myapp", json: schema)
result = cli.parse(["input.txt", "output.txt", "--verbose"])
# => {"file": "input.txt", "output": "output.txt", "verbose": true}
myapp input.txt output.txt --verbose

Variadic Positionals

When the last positional has type: array, it collects all remaining arguments:

schema = %({
  "type": "object",
  "positional": ["files"],
  "properties": {
    "files": {"type": "array", "description": "Input files"},
    "number": {"type": "boolean", "short": "n"}
  }
})

cli = Jargon.cli("cat", json: schema)
result = cli.parse(["-n", "a.txt", "b.txt", "c.txt"])
# => {"number": true, "files": ["a.txt", "b.txt", "c.txt"]}
cat -n a.txt b.txt c.txt

Note: Flags should come before variadic positionals. Collection stops at the first flag encountered.

Short Flags

Define short flag aliases with the short property:

schema = %({
  "type": "object",
  "properties": {
    "verbose": {"type": "boolean", "short": "v"},
    "count": {"type": "integer", "short": "n"},
    "output": {"type": "string", "short": "o"}
  }
})

cli = Jargon.cli("myapp", json: schema)
result = cli.parse(["-v", "-n", "5", "-o", "out.txt"])
# => {"verbose": true, "count": 5, "output": "out.txt"}
myapp -v -n 5 -o out.txt
myapp --verbose --count 5 --output out.txt  # equivalent

Help Flags

Jargon automatically detects --help and -h flags. When using run, help is printed and the program exits automatically:

cli = Jargon.cli("myapp", json: schema)
cli.run do |result|
  # This block only runs if --help was NOT passed
  puts result.to_pretty_json
end
myapp --help           # top-level help
myapp -h               # same
myapp fetch --help     # subcommand help
myapp config set -h    # nested subcommand help

If you need manual control, use parse instead:

result = cli.parse(ARGV)

if result.help_requested?
  if subcmd = result.help_subcommand
    puts cli.help(subcmd)
  else
    puts cli.help
  end
  exit 0
end

If you define a help property or use -h as a short flag for something else, Jargon won't intercept those flags:

# User-defined help property takes precedence
schema = %({
  "type": "object",
  "properties": {
    "help": {"type": "string", "description": "Help topic"},
    "host": {"type": "string", "short": "h"}
  }
})

cli = Jargon.cli("myapp", json: schema)
result = cli.parse(["--help", "topic"])
result.help_requested?  # => false
result["help"].as_s     # => "topic"

result = cli.parse(["-h", "localhost"])
result["host"].as_s     # => "localhost"

Shell Completions

Jargon can generate shell completion scripts for bash, zsh, and fish. When using run, the --completions <shell> flag is handled automatically:

Installing Completions

Generate the completion script once and save it to your shell's completions directory:

# Bash
myapp --completions bash > ~/.local/share/bash-completion/completions/myapp

# Zsh (ensure ~/.zfunc is in your fpath)
myapp --completions zsh > ~/.zfunc/_myapp

# Fish
myapp --completions fish > ~/.config/fish/completions/myapp.fish

The generated scripts provide completions for:

Manual Completion Handling

If you need manual control, use parse:

cli = Jargon.cli("myapp", json: schema)
result = cli.parse(ARGV)

if result.completion_requested?
  case result.completion_shell
  when "bash" then puts cli.bash_completion
  when "zsh"  then puts cli.zsh_completion
  when "fish" then puts cli.fish_completion
  end
  exit 0
end

Subcommands

Create CLIs with subcommands, each with their own schema:

cli = Jargon.new("myapp")

cli.subcommand("fetch", json: %({
  "type": "object",
  "positional": ["url"],
  "properties": {
    "url": {"type": "string", "description": "Resource URL"},
    "depth": {"type": "integer", "short": "d"}
  },
  "required": ["url"]
}))

cli.subcommand("save", json: %({
  "type": "object",
  "properties": {
    "message": {"type": "string", "short": "m"},
    "all": {"type": "boolean", "short": "a"}
  },
  "required": ["message"]
}))

cli.run do |result|
  case result.subcommand
  when "fetch"
    url = result["url"].as_s
    depth = result["depth"]?.try(&.as_i64)
  when "save"
    message = result["message"].as_s
    all = result["all"]?.try(&.as_bool) || false
  end
end
myapp fetch https://example.com/resource -d 1
myapp save -m "Updated config" -a

Nested Subcommands

Create nested subcommands by passing a CLI instance as the subcommand:

config = Jargon.new("config")
config.subcommand("set", json: %({
  "type": "object",
  "positional": ["key", "value"],
  "properties": {
    "key": {"type": "string"},
    "value": {"type": "string"}
  },
  "required": ["key", "value"]
}))
config.subcommand("get", json: %({
  "type": "object",
  "positional": ["key"],
  "properties": {
    "key": {"type": "string"}
  }
}))

cli = Jargon.new("myapp")
cli.subcommand("config", config)
cli.subcommand("status", json: %({"type": "object", "properties": {}}))

cli.run do |result|
  case result.subcommand
  when "config set"
    key = result["key"].as_s
    value = result["value"].as_s
  when "config get"
    key = result["key"].as_s
  when "status"
    # ...
  end
end
myapp config set api_url https://api.example.com
myapp config get api_url
myapp status

The result.subcommand returns the full path as a space-separated string (e.g., "config set").

Default Subcommand

Set a default subcommand to use when no subcommand name is given:

cli = Jargon.new("xerp")

cli.subcommand("index", json: %({...}))
cli.subcommand("query", json: %({
  "type": "object",
  "positional": ["query_text"],
  "properties": {
    "query_text": {"type": "string"},
    "top": {"type": "integer", "default": 10, "short": "n"}
  }
}))

cli.default_subcommand("query")
# These are equivalent:
xerp query "search term" -n 5
xerp "search term" -n 5

Note: If the first argument matches a subcommand name, it's treated as a subcommand, not as input to the default. Use the explicit form if you need to search for a term that matches a subcommand name.

Subcommand Abbreviations

Subcommands can be abbreviated to any unique prefix (minimum 3 characters):

$ myapp checkout main   # full name
$ myapp check main      # abbreviated (if unambiguous)
$ myapp che main        # still works

$ myapp ch main         # too short (< 3 chars) - error
$ myapp co main         # ambiguous (commit? config?) - error

Global Options

Use Jargon.merge to add common options to all subcommands:

global = %({
  "type": "object",
  "properties": {
    "verbose": {"type": "boolean", "short": "v", "description": "Verbose output"},
    "config": {"type": "string", "short": "c", "description": "Config file path"}
  }
})

cli = Jargon.new("myapp")

cli.subcommand("fetch", json: Jargon.merge(%({
  "type": "object",
  "positional": ["url"],
  "properties": {
    "url": {"type": "string"},
    "depth": {"type": "integer", "short": "d"}
  }
}), global))

cli.subcommand("sync", json: Jargon.merge(%({
  "type": "object",
  "properties": {
    "force": {"type": "boolean", "short": "f"}
  }
}), global))
myapp fetch https://example.com/data -v
myapp sync --force --config myconfig.json

Subcommand properties take precedence if there's a conflict with global properties.

File-Based Subcommands

Load subcommands from external files for cleaner organization:

cli = Jargon.new("myapp")
cli.subcommand("fetch", file: "schemas/fetch.yaml")
cli.subcommand("save", file: "schemas/save.json")

Or define all subcommands in a single multi-document file:

# commands.yaml
---
name: fetch
type: object
properties:
  url: {type: string}
---
name: save
type: object
properties:
  file: {type: string}
# Load as top-level subcommands
cli = Jargon.cli("myapp", file: "commands.yaml")
# or
cli = Jargon.new("myapp")
cli.subcommand(file: "commands.yaml")

Load multi-doc as nested subcommands by providing a parent name:

cli = Jargon.new("myapp")
cli.subcommand("config", file: "config_commands.yaml")  # config get, config set, etc.

Multi-document format is auto-detected for json:, yaml:, and file: parameters. Each document must have a name field.

JSON uses relaxed JSONL (consecutive objects with whitespace):

{
  "name": "fetch",
  "type": "object",
  "properties": {"url": {"type": "string"}}
}
{
  "name": "save",
  "type": "object",
  "properties": {"file": {"type": "string"}}
}

Schema Mixins

Share properties across subcommands using standard JSON Schema $id, $ref, and allOf:

---
$id: global
properties:
  verbose: {type: boolean, short: v}
  config: {type: string, short: c}
---
$id: output
properties:
  format: {type: string, enum: [json, yaml, csv]}
---
name: fetch
allOf:
  - {$ref: global}
  - properties:
      url: {type: string}
---
name: export
allOf:
  - {$ref: global}
  - {$ref: output}
  - properties:
      file: {type: string}

This approach uses standard JSON Schema keywords while keeping mixin definitions alongside subcommands in a single file.

JSON from Stdin

Use - to read JSON input from stdin:

# JSON with subcommand field
echo '{"subcommand": "query", "query_text": "search term", "top": 5}' | xerp -

# JSON args for explicit subcommand
echo '{"result_id": "abc123", "useful": true}' | xerp mark -

If no subcommand field is present in xerp -, the default subcommand is used (if set).

The field name is configurable:

cli.subcommand_key("op")  # default is "subcommand"
echo '{"op": "query", "query_text": "search"}' | xerp -

Environment Variables

Map schema properties to environment variables with the env property:

schema = %({
  "type": "object",
  "properties": {
    "api-key": {"type": "string", "env": "MY_APP_API_KEY"},
    "host": {"type": "string", "env": "MY_APP_HOST", "default": "localhost"},
    "debug": {"type": "boolean", "env": "MY_APP_DEBUG"}
  }
})

cli = Jargon.cli("myapp", json: schema)
cli.run do |result|
  # result contains api-key, host from env, debug from CLI
end
export MY_APP_API_KEY=secret123
export MY_APP_HOST=prod.example.com
myapp --debug  # api-key and host from env, debug from CLI

Merge order (highest priority first):

  1. CLI arguments
  2. Environment variables
  3. Config file defaults
  4. Schema defaults

Config Files

Load configuration from standard XDG locations with load_config. Supports YAML and JSON:

cli = Jargon.cli("myapp", json: schema)
config = cli.load_config  # Returns JSON::Any or nil
cli.run(defaults: config) do |result|
  # ...
end

Paths searched (first found wins, or merged if merge: true):

  1. ./.config/myapp.yaml / .yml / .json (project local)
  2. ./.config/myapp/config.yaml / .yml / .json (project local, directory style)
  3. $XDG_CONFIG_HOME/myapp.yaml / .yml / .json (user global, typically ~/.config)
  4. $XDG_CONFIG_HOME/myapp/config.yaml / .yml / .json (user global, directory style)

YAML is preferred over JSON when both exist at the same location.

By default, configs are deep-merged with project overriding user:

# Merge all found configs (default) - project wins over user
config = cli.load_config

# Or first-found wins
config = cli.load_config(merge: false)

Deep Merge

Nested objects are recursively merged, not overwritten:

# User config (~/.config/myapp.yaml)
database:
  host: localhost
  port: 5432
  user: default_user

# Project config (.config/myapp.yaml)
database:
  host: production.example.com

# Result after merge:
database:
  host: production.example.com  # from project
  port: 5432                    # preserved from user
  user: default_user            # preserved from user

Config Warnings

Invalid config files emit warnings to STDERR by default. To suppress:

Jargon.config_warnings = false
config = cli.load_config
Jargon.config_warnings = true

Example project config (.config/myapp.yaml):

host: localhost
port: 8080
debug: true

Or JSON (.config/myapp.json):

{
  "host": "localhost",
  "port": 8080,
  "debug": true
}

The defaults: parameter accepts any JSON-like data, so you can load config however you prefer:

# From YAML
config = YAML.parse(File.read("config.yaml"))
cli.run(defaults: config) { |result| ... }

# From JSON
config = JSON.parse(File.read("settings.json"))
cli.run(defaults: config) { |result| ... }

Standalone Validator

Use Jargon::Validator to validate data against a schema without the CLI parser. This is useful for validating JSON from APIs, config files, or other sources:

require "jargon"

schema = Jargon::Schema.from_json(%({
  "type": "object",
  "properties": {
    "name": {"type": "string", "minLength": 1},
    "age": {"type": "integer", "minimum": 0},
    "role": {"type": "string", "enum": ["admin", "user"]}
  },
  "required": ["name"],
  "additionalProperties": false
}))

data = {"name" => JSON::Any.new("Alice"), "age" => JSON::Any.new(30_i64)}
errors = Jargon::Validator.validate(data, schema)
# => [] (empty = valid)

bad_data = {"name" => JSON::Any.new(""), "extra" => JSON::Any.new("?")}
errors = Jargon::Validator.validate(bad_data, schema)
# => ["Value for name must be at least 1 characters",
#     "Unknown property 'extra': additionalProperties is false"]

The validator supports all the same constraints as CLI parsing: types, required fields, enums, numeric ranges, string patterns, formats, array constraints, const, $ref, nested objects, and additionalProperties.

API

# Create CLI (program name first, named schema parameter)
cli = Jargon.cli(program_name, json: json_string)
cli = Jargon.cli(program_name, yaml: yaml_string)
cli = Jargon.cli(program_name, file: "schema.json")

# For subcommands (no root schema)
cli = Jargon.new(program_name)
cli.subcommand("name", json: schema_string)
cli.subcommand("name", yaml: schema_string)
cli.subcommand("name", file: "schema.yaml")      # single-doc file
cli.subcommand(file: "commands.yaml")            # multi-doc as top-level
cli.subcommand("parent", file: "commands.yaml")  # multi-doc as nested

# Merge global options into subcommand schema
merged = Jargon.merge(subcommand_schema, global_schema)

# Run with automatic help/completions/error handling (recommended)
cli.run { |result| puts result.to_pretty_json }
cli.run(ARGV) { |result| ... }
result = cli.run                      # without block, returns Result

# Parse arguments - returns Result with errors array
result = cli.parse(ARGV)
result = cli.parse(ARGV, defaults: config)

# Get data as JSON - returns JSON::Any, raises ParseError on errors
data = cli.json(ARGV)
data = cli.json(ARGV, defaults: config)

# Config file loading
config = cli.load_config              # merge all found configs (project wins)
config = cli.load_config(merge: false) # first found wins
paths = cli.config_paths              # list of paths searched

# Result methods (from parse or run)
result.valid?      # => true/false
result.errors      # => Array(String)
result.data        # => JSON::Any
result.to_json         # => compact JSON string
result.to_pretty_json  # => formatted JSON string
result["key"]          # => access values
result.subcommand      # => String? (nil if no subcommands)

# Help/completion detection (when using parse)
result.help_requested?  # => true if --help/-h was passed
result.help_subcommand  # => String? (which subcommand's help, nil for top-level)
result.completion_requested?  # => true if --completions was passed
result.completion_shell       # => String? ("bash", "zsh", or "fish")

# Help text
cli.help              # => usage string with all options
cli.help("fetch")     # => help for specific subcommand
cli.help("config set") # => help for nested subcommand

# Completion scripts
cli.bash_completion  # => bash completion script
cli.zsh_completion   # => zsh completion script
cli.fish_completion  # => fish completion script

# Standalone validation (no CLI needed)
errors = Jargon::Validator.validate(data_hash, schema)  # => Array(String)

Development

Prerequisites

Running Tests

shards install
crystal spec

Project Structure

src/
├── jargon.cr              # Main module, convenience methods
└── jargon/
    ├── cli.cr             # Core CLI parser
    ├── schema.cr          # JSON Schema parsing
    ├── schema/property.cr # Property definitions
    ├── result.cr          # Parse result container
    ├── validator.cr       # Standalone schema validator
    ├── config.cr          # Config file loading (XDG)
    ├── help.cr            # Help text generation
    └── completion.cr      # Shell completion scripts
spec/
└── jargon_spec.cr         # Test suite

Building Docs

crystal docs
open docs/index.html

License

MIT