SKILL.md
jo
Build JSON in shell snippets with jo, not by hand-typing a quoted JSON literal. jo handles JSON quoting internally, so shell variables interpolate cleanly and you never juggle nested single/double quotes.
Authoritative documentation: https://github.com/jpmens/jo (README + jo.md man page). Verify any non-trivial form against the manual before relying on it.
Why
- Hand-written JSON in a shell string is a quote-escaping minefield.
Inserting $VAR into '{"user":"x"}' either breaks the single quotes or forces an awkward '"$VAR"' pattern that is hard to read and easy to mistype.
- Typos in inline JSON (missing comma, stray quote, trailing comma)
fail at the server, not at parse time — hard to spot in a long single-line payload.
jofigures out types from the value:
numbers, booleans, null, nested objects, and arrays are detected automatically; override with -s/-n/-b per word when the guess would be wrong (e.g. a numeric ID that must stay a string).
jocomposes:
shell variables, command substitution, bracket-path shorthands, and nested jo calls all work without re-escaping.
How jo types a value
Per the man page, the value pipeline is:
- Try to parse the value as JSON.
5 → number, true/false → boolean, null → null, {...} / [...] → embedded object/array, strings that look numeric but aren't valid floats (e.g. 3.14159.26) → string.
- If JSON parse fails,
check special value prefixes: @file, %file, :file (see Reading from files below).
- Otherwise it's a literal string.
A missing/empty value (k=) becomes null. Pass -B to disable detection of the literal strings true/false/null. Globally, -n drops keys with empty values (not to be confused with the per-word -n coercion after --).
Direct substitutions
| Don't | Do |
|---|---|
'{"name":"alice"}' |
"$(jo name=alice)" |
'{"name":"'"$USER"'"}' |
"$(jo name="$USER")" |
'{"count":5,"active":true}' |
"$(jo count=5 active=true)" |
'{"value":null}' |
"$(jo value=null)" (or "$(jo value=)") |
'{"tags":["a","b","c"]}' |
"$(jo tags="$(jo -a a b c)")" |
'{"point":[1,2]}' |
"$(jo 'point[]=1' 'point[]=2')" |
'{"geo":{"lat":10,"lon":20}}' |
"$(jo 'geo[lat]=10' 'geo[lon]=20')" |
'{"user":{"id":1,"name":"alice"}}' |
"$(jo user="$(jo id=1 name=alice)")" |
'{"code":"12345"}' (string ID) |
"$(jo -- -s code=12345)" |
Bracket-path forms (key[]=, key[sub]=) are one level deep only — 'a[b][c]=1' silently drops [c] and produces {"a":{"b":1}}. For deeper nesting, use nested jo via command substitution.
Syntax cheatsheet
Value forms
k=v—
JSON-parsed then prefix-checked then literal string (see How jo types a value).
k@v—
boolean from a truthy value (T/t or non-zero numeric → true, else false); handy for flag@$ENABLED.
k=@file—
load file contents as a string value.
k=%file—
load file as base64-encoded string.
k=:file—
parse file as JSON and embed.
k:=fileork:=-—
same as k=:file but as an operator; - reads JSON from stdin.
To use a literal @, %, or : at the start of a string value, escape it with \: twitter='\@jpmens', vimcmd='\:split', uri='\\%20'.
Bracket paths (one level)
'name[]=v1' 'name[]=v2'—
appends to the name array.
'obj[k]=v'—
creates {"obj":{"k":v}}.
- Combine
-d.
(path separator) for dot notation: jo -d. geo.lat=10 geo.lon=20 is equivalent to jo 'geo[lat]=10' 'geo[lon]=20'.
- Always quote bracket forms —
unquoted [ / ] get caught by zsh globbing (and bash too, if any expansion matches).
Arrays
jo -a v1 v2 v3—
array of auto-typed values.
jo -a -- -s 123 hello -n "five"—
per-word coercion inside an array (here: ["123","hello",4] — -n on a non-numeric string yields its length).
Type coercion
jo -- -s k=v—
force string. Use for numeric IDs, version strings, phone numbers, ZIPs.
jo -- -n k=v—
force number (non-numeric strings become their length).
jo -- -b k=v—
force boolean (empty → false, anything else → true).
--is mandatory before the word list whenever the first word starts
with -; pass it always for consistency with the manual.
Global options
The two most-used:
-p—
pretty-print (drop for production payloads).
-a—
emit an array instead of an object.
For the full option list (-B, -D, -n global, -d sep, -f file, -e, -o, -v/-V, plus per-word coercion semantics after --) see references/options.md.
Idioms
curl POST with a body:
curl -X POST https://api.example.com/v1/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jo name="$NAME" email="$EMAIL" active=true)"
Bracket shorthand for one-level nesting (no nested jo substitution needed):
curl -X POST https://api.example.com/v1/orders \
-H "Authorization: Bearer $TOKEN" \
-d "$(jo \
'customer[id]='"$CUSTOMER_ID" \
'customer[name]='"$CUSTOMER_NAME" \
'items[]='"$SKU_A" \
'items[]='"$SKU_B")"
Nested deeper than one level — build via command substitution (or stash in a variable):
payload=$(jo \
name="$NAME" \
address="$(jo street="$STREET" city="$CITY")" \
tags="$(jo -a admin user)")
curl -X POST -d "$payload" https://api.example.com/v1/users
kubectl patch:
kubectl patch deployment web \
--type=merge \
--patch "$(jo spec="$(jo replicas=3)")"
Numeric ID that must stay a string (otherwise jo emits "id":12345 as a number):
curl -X POST -d "$(jo -- -s id=12345 name="$NAME")" \
https://api.example.com/v1/users
Modify an existing JSON document:
curl -sSL https://api.example.com/v1/config \
| jo -f - updated_at="$(date -Iseconds)" version="$NEW_VERSION"
When NOT to use jo
Large, fixed JSON document
A heredoc with literal JSON is clearer than a long chain of jo calls, and the JSON can be copy-pasted from API docs without translation:
curl -X POST -d @- https://api.example.com/v1/config <<'JSON'
{
"feature_flags": {
"alpha": true,
"beta": false
},
"limits": {
"rps": 100,
"burst": 200
}
}
JSON
jo not available on the host
Some minimal CI images or locked-down servers lack it. Fall back to a heredoc; do not fall back to fragile inline quoted JSON.
Installing jo
- macOS:
brew install joorsudo port install jo - Debian/Ubuntu:
apt install jo - Fedora:
dnf install jo - Alpine:
apk add jo - Gentoo:
emerge jo - Arch:
pacman -S jo(inextra) - Snap:
snap install jo - Windows:
scoop install jo - Docker:
docker run --rm jpmens/jo ... - From source: https://github.com/jpmens/jo
jo is not in the default aqua/mise short-name registry; on a mise-managed host install via the package manager or via ubi:jpmens/jo.
Scope
Applies anywhere a shell snippet emits JSON:
Bashtool invocations.- Snippets in
SKILL.md/README.md/ docs. - Scripts under
scripts/. - Makefile recipes.
- CI configs.
- API smoke-test instructions.
If you encounter a hand-written inline JSON string while editing a file, replace it with the equivalent jo form as part of the same edit.