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"},
    "config": {"type": "string", "format": "path"},
    "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.

End of Options (--)

A bare -- marks the end of options (POSIX convention). Everything after it is treated as a positional argument, even if it looks like a flag — useful for passing flags through to another program. The -- marker itself is discarded, and a variadic positional captures the rest verbatim:

schema = %({
  "type": "object",
  "positional": ["argv"],
  "properties": {
    "verbose": {"type": "boolean", "short": "v"},
    "argv": {"type": "array"}
  }
})

cli = Jargon.cli("run", json: schema)
result = cli.parse(["--verbose", "--", "ls", "-la", "--color"])
# => {"verbose": true, "argv": ["ls", "-la", "--color"]}

Options before -- parse normally; only the first -- is special (a second one is a literal operand).

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.

The generated scripts are small, fixed shims: on every Tab they forward the cursor position and typed words back to your program (a hidden __complete verb that run and handle_completion recognize), and the program computes the candidates from the schema. This means one code path serves both static completion (subcommand names, flags, enum values) and dynamic completion (live data only your app knows) — and the installed script never needs regenerating when your CLI changes.

Installing Completions

Generate the completion shim 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

Out of the box you get completion for subcommand names (including nested), long and short flags, and enum values — all derived from the schema, no extra code.

Dynamic Completions

For values the schema can't know — a document name, a tag, a key in a live store — register a completer against a field. It runs inside your program at completion time and returns the candidates:

cli = Jargon.cli("transfs", yaml: schema)

# Field path: the field name for a flat CLI, or `subcommand.field`
# (`a.b.field` when nested). Raises if the path isn't a real field.
cli.completer("forget.query") do |ctx|
  store.search(ctx.partial).map(&.name)   # candidates (Array(String))
end

cli.run { |result| ... }   # `run` answers the __complete verb automatically

The block receives a Completion::Context:

| Field | Description | |-------|-------------| | ctx.partial | the token being completed (use it to scope your query) | | ctx.subcommand | the resolved subcommand path, or nil at top level | | ctx.arguments | a lenient parse of what's already typed, for filtering | | ctx.words | the raw tokens, as an escape hatch |

A registered field completes dynamically; everything else stays static. You never write the __complete verb yourself — the shim supplies it.

Answering completion from a separate binary

Each Tab spawns whatever the shim calls. If your main program is heavy to start, point the shim at a small dedicated binary instead of paying full startup per keypress:

# generate a shim that calls `transfs-complete` rather than `transfs`
File.write(path, cli.bash_completion(command: "transfs-complete"))
# transfs-complete.cr — a minimal helper that only answers completion
cli = build_cli
cli.completer("forget.query") { |ctx| index.search(ctx.partial) }
exit cli.handle_completion   # answers __complete and exits

handle_completion returns immediately for a normal invocation, so you can also call it at the very top of your existing main to answer completion before heavy initialization runs, without a second binary. (The separate-binary win only materializes if your candidate source has a light access path — an on-disk index, a cache, a daemon — rather than requiring the full app to boot.)

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| ... }

Extension Annotations (x-*)

Schema keys starting with x- are treated as consumer-defined metadata, following the JSON Schema / OpenAPI extension convention. Jargon preserves them verbatim — on commands (doc level) and on individual properties — and ignores them completely for parsing and validation. This gives downstream consumers (a GUI, a docs generator, an AI tool layer) an open vocabulary of hints carried by the same schema that drives the CLI:

---
name: forget
description: Remove a document from the store
x-ui:
  destructive: true        # GUI: confirm before running
properties:
  query:
    type: string
    x-ui:
      control: typeahead   # GUI: render a search box, not a text field
      source: documents

Annotations are exposed through the schema introspection API:

schema = cli.subcommands["forget"].as(Jargon::Schema)

schema.root.extensions             # => {"x-ui" => {"destructive" => true}}
schema.root.extensions["x-ui"]?    # => keys are stored exactly as written

query = schema.root.properties.not_nil!["query"]
query.extensions["x-ui"]["control"]  # => "typeahead"

Jargon assigns no meaning to any x- key — define whatever vocabulary your consumers need. (The built-in service hint predates this mechanism and remains first-class.)

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 shims (pass command: to target a separate helper binary)
cli.bash_completion                       # => bash completion shim
cli.zsh_completion                        # => zsh completion shim
cli.fish_completion                       # => fish completion shim
cli.bash_completion(command: "app-complete")

# Dynamic completers
cli.completer("forget.query") { |ctx| ... }  # register; returns Array(String)
cli.handle_completion                         # answer __complete + exit (no-op otherwise)

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

# Schema introspection (for GUI/tooling consumers)
schema = cli.schema                    # => Jargon::Schema? (root schema, if any)
schema = cli.subcommands["name"]       # => Jargon::Schema | Jargon::CLI
schema.root                            # => Jargon::Property (command-level)
schema.root.properties                 # => Hash(String, Property)?
schema.positional                      # => Array(String)
property.extensions                    # => Hash(String, JSON::Any) (x-* annotations, keys as written)
property.extensions["x-ui"]?           # => JSON::Any?

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