jargon
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
- Validation: Required fields, enum values, strict type checking,
additionalProperties - Standalone validator: Use
Jargon::Validatorto validate data without the CLI parser - Defaults: Schema defaults, config file defaults, environment variables
- Config files: Load from
.config/(XDG spec) with deep merge support - Help text: Generated from schema descriptions
- Auto help flags:
--helpand-hdetected automatically - Shell completions: Generate completion scripts for bash, zsh, and fish
- Positional args: Non-flag arguments assigned by position and variadic support.
- Short flags: Single-character flag aliases (
-v,-n 5) - Boolean flags: Support both
--verboseand--verbose falsestyles - Subcommands: Named sub-parsers with independent schemas (supports abbreviated invocations)
- Default subcommand: Fall back to a subcommand when none specified
- Stdin JSON: Read arguments as JSON from stdin with
- - Typo suggestions: "Did you mean?" for mistyped options
- $ref support: Reuse definitions with
$ref: "#/$defs/typename"
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:
--help/-h: prints help and exits--completions <shell>: prints shell completion script and exits- Validation errors: prints errors to STDERR and exits with code 1
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", "user.email=john@example.com"])
# => {"user": {"name": "John", "email": "john@example.com"}}
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"]}
}
}
}
minimum/maximum: numeric range (inclusive)exclusiveMinimum/exclusiveMaximum: numeric range (exclusive)multipleOf: value must be divisible by this numberminLength/maxLength: string lengthminItems/maxItems: array lengthuniqueItems: no duplicate values in arraypattern: regex validation for stringsformat: semantic formats (email,uri,uuid,date,time,date-time,ipv4,ipv6,hostname)enum: allowed values (works for array items too)const: exact value matchadditionalProperties: whenfalse, rejects unknown keys in objects
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:
- Subcommand names
- Long flags (
--verbose,--output) - Short flags (
-v,-o) - Enum values (e.g.,
--format json|yaml|xml) - Nested subcommands
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}
- Schemas with
$id(noname) are mixins - not registered as subcommands $refinallOfresolves to mixins defined in the same file- Properties are merged;
type: objectis inferred if missing - Subcommands explicitly opt-in via
allOf
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):
- CLI arguments
- Environment variables
- Config file defaults
- 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):
./.config/myapp.yaml/.yml/.json(project local)./.config/myapp/config.yaml/.yml/.json(project local, directory style)$XDG_CONFIG_HOME/myapp.yaml/.yml/.json(user global, typically~/.config)$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
- Crystal >= 1.18.2
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
jargon
- 5
- 0
- 0
- 3
- 1
- 1 day ago
- January 21, 2026
MIT License
Wed, 11 Feb 2026 07:15:36 GMT