README
¶
xy
A stateful CLI template runner. Merge config files, render commands as Go templates, execute them, and chain outputs — all with a persistent SQLite audit trail.
Why xy?
Multi-step CLI workflows often need values from config files and outputs from previous commands. Without xy, you either write long bash scripts where the actual commands are 5% of the file, or copy-paste commands and manually substitute values.
xy sits in between: it gives you config merging, templating, and state — without the boilerplate.
| Without xy | With xy |
|---|---|
| 200-line bash script per task | 1 xy command per step |
Config in .env files, sourced with set -a |
YAML/JSON files, deep-merged in order |
Values via $VARIABLE substitution |
Go templates: {{ .namespace }} |
| No audit trail | Every command's stdout/stderr/exitcode/duration in SQLite |
| Error handling boilerplate | --strict (fail on error), --retry (retry with timeout) |
| Hard to chain outputs | --saveas captures result, key reads it back |
Install
# With Go 1.23+
go install github.com/chitta-pardeshi/xy@latest
# Or build from source
git clone https://github.com/chitta-pardeshi/xy.git
cd xy
go build -o xy .
cp xy /usr/local/bin/
# Verify
xy --init && echo "xy works"
Single binary, no dependencies. ~15MB. Linux/Mac/Windows (WSL).
Quick Start
# Initialize state (creates .xy/state.db)
xy --init
# Load config from YAML files (merged in order, later wins)
xy common.yaml prod.yaml --saveas cfg
# Run a command using config values
xy -- 'echo "Deploying to {{ key "cfg" "environment" }}"'
# Save command output for later use
xy --saveas disk -- 'df -h / --output=avail | tail -1'
# Use saved output in next command
xy -- 'echo "Available disk: {{ key "disk" "stdout" | trim }}"'
# Conditional execution
xy --condition '{{ eq (key "disk" "exitcode") "0" }}' \
-- 'echo "Disk check passed"'
# Retry until success
xy --retry 5s --timeout 60s --strict \
-- 'curl -sf http://localhost:8080/health'
# Generate a report from all saved state
xy --template report.tmpl --output report.txt
Command Reference
Syntax
xy [files...] [options] [-- command...]
Modes
| Mode | Syntax | Purpose |
|---|---|---|
| Init | xy --init |
Create fresh .xy/state.db |
| Save context | xy file1.yaml file2.json --saveas name |
Merge files, save to SQLite |
| Run command | xy [files...] [options] -- command |
Render template, execute, optionally save |
| Report | xy --template file [--output file] |
Render template file against all saved state |
Options
| Option | Description | Example |
|---|---|---|
--init |
Initialize fresh SQLite state in .xy/ |
xy --init |
--set key=value |
Override a config value (repeatable) | --set env=prod --set timeout=600 |
--saveas name |
Save command result (or merged context) to SQLite | --saveas deploy |
--strict |
Exit non-zero if command fails | --strict |
--condition expr |
Only run if template expression is truthy | --condition '{{ eq (key "v" "exitcode") "0" }}' |
--retry interval |
Retry command at interval until success | --retry 5s |
--timeout duration |
Max time for retries | --timeout 300s |
--template file |
Render a template file (report mode) | --template report.tmpl |
--output file |
Write rendered output to file (default: stdout) | --output report.txt |
Config Files
xy auto-detects file format by extension:
| Extension | Format |
|---|---|
.yaml, .yml |
YAML |
.json |
JSON |
Multiple files are deep-merged in order (later file wins):
# base.yaml has env: staging, timeout: 30
# prod.yaml has env: production, replicas: 3
xy base.yaml prod.yaml --saveas cfg
# Result: env=production, timeout=30, replicas=3
--set overrides are applied last:
xy base.yaml prod.yaml --set env=canary --saveas cfg
# env is now "canary"
Dotted paths create nested keys:
xy --set database.host=db.example.com
# creates: {"database": {"host": "db.example.com"}}
Template Functions
Config values (dot context)
Values from merged YAML/JSON files are accessed via dot notation:
{{ .environment }} → production
{{ .database.host }} → db.example.com
{{ .replicas }} → 3
key — read saved state
key retrieves values saved by --saveas from SQLite.
For command results (--saveas with -- command):
{{ key "varname" "stdout" }} → command stdout
{{ key "varname" "stderr" }} → command stderr
{{ key "varname" "exitcode" }} → exit code (as string: "0", "1", etc.)
{{ key "varname" "command" }} → the rendered command that was executed
{{ key "varname" "duration" }} → how long it took (e.g., "2.34s")
{{ key "varname" "timestamp" }} → when it ran (RFC3339)
For saved contexts (--saveas without command):
{{ key "cfg" "environment" }} → field from merged YAML/JSON
{{ key "cfg" "replicas" }} → "3" (always string)
set — save inline
{{ set "myvar" "some value" }} → saves to SQLite, outputs nothing
Sprig v3
All Sprig v3 functions are available. Common ones:
| Function | Example | Result |
|---|---|---|
trim |
{{ key "v" "stdout" | trim }} |
Remove whitespace |
default |
{{ key "v" "stdout" | default "none" }} |
Fallback value |
upper / lower |
{{ upper .env }} |
PROD |
b64enc / b64dec |
{{ b64enc .password }} |
Base64 encode |
contains |
{{ if contains "error" .msg }} |
String search |
eq / ne / gt / lt |
{{ eq (key "v" "exitcode") "0" }} |
Comparison |
ternary |
{{ ternary "yes" "no" .flag }} |
If/else |
now |
{{ now | date "2006-01-02" }} |
Current date |
join |
{{ list "a" "b" | join "," }} |
a,b |
toYaml |
{{ toYaml .data }} |
Render as YAML |
State Management
How it works
xy --init → creates .xy/state.db (SQLite, WAL mode)
xy ... --saveas name -- command → saves result to "results" table
xy file.yaml --saveas name → saves merged context to "contexts" table
xy -- '{{ key "name" "field" }}' → reads from either table
What gets saved
Every --saveas with a command stores:
| Field | Description |
|---|---|
stdout |
Command standard output |
stderr |
Command standard error |
exitcode |
Exit code (0 = success) |
command |
The rendered command string |
duration |
Execution time (e.g., "2.34s") |
timestamp |
When it ran (RFC3339 UTC) |
Every --saveas without a command stores the merged YAML/JSON map.
Persistence
State persists across xy invocations in the same directory. This is what enables chaining:
xy --saveas step1 -- 'echo 10.0.0.1' # saves stdout
xy -- 'echo "Server IP is {{ key "step1" "stdout" | trim }}"' # reads it back
xy --init clears all state (fresh start).
Concurrency
SQLite is opened in WAL mode with busy_timeout=5000ms. Multiple xy processes can safely read/write concurrently.
Execution Control
--strict
Fail immediately if the command exits non-zero:
# Without --strict: saves exitcode=1, continues
xy --saveas check -- 'exit 1'
# With --strict: xy itself exits with error
xy --strict -- 'exit 1'
# xy: exit code 1
# stderr: ...
--condition
Gate execution on a template expression. If it evaluates to false, 0, or empty string, the command is skipped:
# Only proceed if prereqs passed
xy --saveas prereq -- './check-prereqs.sh'
xy --condition '{{ eq (key "prereq" "exitcode") "0" }}' \
--saveas deploy --strict \
-- 'make deploy'
If the condition is false, no command runs and no --saveas is written.
--retry / --timeout
Retry a command at a fixed interval until it succeeds (exit 0) or timeout:
# Poll every 5s, give up after 5 minutes
xy --retry 5s --timeout 300s --strict \
-- 'curl -sf http://localhost:8080/health'
The last attempt's result is saved (if --saveas is used). On timeout with --strict, xy exits with error.
Command wrapping
Everything after -- is joined into a single string and executed via bash -c. This means pipes, redirects, loops, and subshells all work:
xy -- 'echo hello | tr a-z A-Z'
xy -- 'for i in 1 2 3; do echo $i; done'
xy -- 'cat /etc/hosts | grep localhost | wc -l'
Report Generation
Render a template file against all saved state:
# To stdout
xy --template report.tmpl
# To file
xy --template report.tmpl --output report.txt
# From stdin (heredoc)
xy --template /dev/stdin <<'EOF'
Environment: {{ key "cfg" "environment" }}
Deploy: exit={{ key "deploy" "exitcode" }}, took {{ key "deploy" "duration" }}
EOF
Templates have access to all saved results and contexts via key.
Example: Multi-Step Deploy Workflow
# 1. Init + load config
xy --init
xy base.yaml prod.yaml \
--set version=2.1.0 \
--saveas cfg
# 2. Check prereqs
xy --saveas ctx --strict -- 'kubectl config current-context'
xy --saveas ns --strict -- 'kubectl get ns {{ key "cfg" "namespace" }} -o jsonpath={.status.phase}'
# 3. Deploy
xy --saveas deploy --strict \
-- 'helm install myapp ./chart --namespace {{ key "cfg" "namespace" }} --set image.tag={{ key "cfg" "version" }}'
# 4. Wait for rollout
xy --saveas wait --strict --retry 5s --timeout 300s \
-- 'kubectl rollout status deployment/myapp -n {{ key "cfg" "namespace" }}'
# 5. Verify
xy --saveas health --strict \
-- 'curl -sf http://{{ key "cfg" "endpoint" }}/health'
# 6. Report
xy --template /dev/stdin <<'EOF'
Deploy Report
Version: {{ key "cfg" "version" }}
Deploy: exit={{ key "deploy" "exitcode" }}, took {{ key "deploy" "duration" }}
Rollout: {{ key "wait" "duration" }}
Health: {{ key "health" "exitcode" }}
EOF
Architecture
+-------------+
| YAML/JSON | config files
| files | (merged in order)
+------+------+
|
+------v------+
| --set k=v | overrides
+------+------+
|
+------------------------v------------------------+
| xy |
| |
| +----------+ +-----------+ +--------------+ |
| | Config | | Sprig v3 | | bash -c | |
| | Merge |->| Template |->| Execute | |
| | | | Render | | | |
| +----------+ +-----------+ +------+-------+ |
| | |
| +--------v-------+ |
| | SQLite DB | |
| | (WAL mode) | |
| | | |
| | results table | |
| | contexts table | |
| +----------------+ |
+-------------------------------------------------+
Comparison
| Feature | xy | Bash script | Makefile | Argo Workflows |
|---|---|---|---|---|
| Single binary | yes | N/A | yes | K8s cluster needed |
| Config merge | YAML/JSON deep merge | source .env | variables | parameters |
| Templates | Sprig v3 (Helm-compatible) | $VAR substitution | $() | Go templates |
| State between steps | SQLite (key/set) | temp files / env vars | none | artifacts |
| Retry/timeout | built-in | manual loop | manual | built-in |
| Conditional | --condition (template expr) | if/then | ifeq | when |
| Audit trail | every step in SQLite | none | none | K8s events |
| Portable | any machine with bash | any machine | needs make | needs K8s |
Contributing
Contributions are welcome. Please open an issue to discuss larger changes before submitting a PR.
# Run tests before submitting
go test -v ./...
License
Documentation
¶
There is no documentation for this package.