velvet

The rope between your terminal and your app.

Define prompts in YAML (or use the lib/DSL in Crystal). Collect clean, typed input. Emit JSON. Your app never writes argparse again.


Install

git clone https://github.com/aristorap/velvet
cd velvet
shards install
shards build --release
# binary at bin/velvet

Usage

# Generate a new YAML wizard from shorthand field specs
velvet new "Deploy config" app_name replicas:int frontend@one_of=vanilla,vue dry_run@confirm

# Interactive wizard
velvet run deploy_config.yml

# Non-interactive — parse flags against schema
velvet parse deploy_config.yml -- --app-name myapp --replicas 3 --frontend vue --dry-run

# Validate a wizard file
velvet validate deploy_config.yml

# Run the DSL deploy example
crystal run examples/dsl.deploy.cr

Commands

| command | aliases | short description | | --------------------------- | ------------ | ------------------------------------------ | | new [name] <shorthand>... | n, init | Scaffold a new velvet config | | run [file] | r | Run an interactive wizard and emit JSON | | parse [file] -- [flags] | p | Parse flags against a schema and emit JSON | | validate [file] | v, check | Validate a wizard file |

Long descriptions are available in command help:

velvet --help
velvet new --help
velvet run --help
velvet parse --help
velvet validate --help

Missing required positional arguments print a command-specific usage hint (including aliases):

Error: run requires a file argument
Usage: velvet run [file]
Alias: r

Alias examples:

velvet n "Deploy config" app_name replicas:int
velvet r deploy_config.yml
velvet p deploy_config.yml -- --app-name myapp --replicas 3
velvet v deploy_config.yml

The new command writes a normalized .yml file in the current directory.

Pipe into your app:

config=$(velvet run deploy.yml)
myapp <<< "$config"

# or
velvet run deploy.yml | myapp deploy

Your app just reads JSON from stdin — no argparse, no type coercion, no required-field checks.

Interactive prompt behavior:


Generator field shorthand

Supported shorthand format:

field       := id
            | id:cast
            | id@ui
            | id:cast@ui
            | id:cast@ui=val,val,...

cast        := str | string | int | float | bool
ui          := input | select | multi(select) | confirm
ui_alias    := one_of -> select | any_of -> multi
values      := csv list, valid only with select-family or multi-family ui

Examples:

app_name
app_name:str
replicas:int
replicas:int@select=1,2,4,8
frontend@select=vanilla,vue
dry_run@confirm
tags@multi=cache,metrics

Defaults and implications:

Built-in UI aliases:

Custom aliases can be registered from Crystal code while the API evolves:

Velvet::Generator.set_ui_alias("pick", "select")
Velvet::Generator.set_ui_alias("many", "multi")

Reset aliases back to defaults:

Velvet::Generator.reset_ui_aliases

DSL has matching user-facing aliases:

w.input "replicas", "Number of replicas", cast: Velvet::Cast::Int
w.one_of "environment", "Environment", ["dev", "prod"]
w.multi "tags", "Tags", ["cache", "metrics"]
w.any_of "tags", "Tags", ["cache", "metrics"]
w.field "tags", "Tags", ui: "multiselect", options: ["cache", "metrics"]

Wizard file

name: "Deploy config"

fields:
  - id: environment
    type: select
    label: "Target environment"
    options: [dev, staging, production]
    default: dev

  - id: replicas
    type: input
    label: "Number of replicas"
    cast: int
    required: true
    validate:
      min: 1
      max: 20

  - id: dry_run
    type: confirm
    label: "Dry run?"
    default: false

  - id: tags
    type: multiselect
    label: "Feature flags"
    options: [cache, metrics, tracing]
    required: false

Field types

| type | description | | ------------- | ---------------------------------------- | | input | free text, optionally cast and validated | | select | pick one from a list | | multiselect | pick many | | confirm | yes/no boolean |

Cast types

string (default), int, float, bool

For select and multiselect fields, cast is also supported and is applied in both run and parse flows.

Validation

validate:
  min: 1
  max: 100
  pattern: "^[a-z-]+$"

Validation rules are cast-aware and checked when loading YAML or building via DSL:

Invalid combinations are rejected as schema/config errors (exit code 2) rather than being ignored.

Examples of invalid combinations:

# invalid: numeric bounds on string
cast: string
validate:
  min: 1

# invalid: pattern on int
cast: int
validate:
  pattern: "^[0-9]+$"

Output

Always clean JSON to stdout, errors to stderr.

{
  "environment": "staging",
  "replicas": 3,
  "dry_run": false,
  "tags": ["cache", "metrics"]
}

Types are cast before emission — consumers always get typed values.


Exit codes

| code | meaning | | ----- | --------------------- | | 0 | success | | 1 | validation error | | 2 | config/schema error | | 130 | user aborted (Ctrl+C) |