workoutmd — the CLI
A single, static, offline binary that reads workout.mdand lints, formats, converts, and hydrates it. Its defining feature: every diagnostic is written to be actioned by an agent with no other context— a code, a location, a caret, a plain-language reason, a concrete fix, and a spec deep link. The core makes zero network calls and phones nobody.
This page is the reference. The same content ships inside the binary:workoutmd man emits a man page, workoutmd help <command>gives per-command detail, and both — like this page — render from one command registry, so they can't drift.
For local training data, sessions, goals, file exchange, and assistant tools, see the sister Tensr CLI. Install either or both.
Install
One command, any platform. It detects your OS and architecture (darwin/linux × amd64/arm64), downloads the matching static binary fromdl.tensr.fitness, verifies its sha256, and installs it — no account, zero credentials:
curl -fsSL https://dl.tensr.fitness/install.sh | shIt lands in ~/.local/bin (or /usr/local/bin if writable) and needs only curl-or-wget andshasum-or-sha256sum. Prefer to grab a binary by hand? Every build is atdl.tensr.fitness/workoutmd/latest/workoutmd_<os>_<arch>with a sibling checksums.txt and workoutmd.1 man page.
When a terminal is available, the installer offers to install the missingtensr companion. Use --no-companion to skip or--with-companion to include it explicitly. Unattended installs skip the prompt. Once installed, check and approve upgrades:
workoutmd update --check # is a newer release available?
workoutmd upgrade # offer [y/N], verify sha256, self-replace
workoutmd upgrade --yes # approve unattended installation
workoutmd man | man -l - # read the manual (or: mandoc -a)The agent loop
lint is the default command. Run it with --json, apply each diagnostic's fix, and re-run until ok is true. Exit status drives the loop: 0 clean, 1 findings, 2 a usage or I/O error.
while ! out=$(workoutmd lint --json routine.workout.md); do
# exit 1 → out holds { "ok": false, "diagnostics": [ … ] }
# read each .diagnostics[].fix, edit the file, loop
doneCommands
Reads file arguments or stdin; human output to stdout, errors to stderr.
| Command | Does |
|---|---|
lint [file…] | validate + lint (default command); human report or --json |
parse [file…] | emit the parsed AST as JSON |
fmt [--write] [file…] | canonicalize (parse → serialize); idempotent |
from-json [file] | AST JSON → workout.md text (inverse of parse) |
from-csv [file] | CSV (one row per movement) → workout.md text (or --json) |
csv-template | emit a ready-to-fill CSV to seed a spreadsheet |
new [--list] <template> | scaffold a routine from a built-in template (--list shows them) |
diff <a> <b> | what changed between two routines (--json too) |
share [file] | routine → share link, terminal QR by default |
read-link [link] | decode a tensr link → JSON (a decode, not a fetch) |
to <qr|html|pdf|csv> [file] | render a routine to a target |
swap --to <chain> [--explain] [file] | deterministic equipment swap (cable / db / suspension; comma = ordered fallback); reports unmapped, flags loads; --explain shows which map layer won |
maps <install|ls|check|update|lock|sync> | install + version layered swap maps (fetched over HTTPS, sha-verified, offline forever); lock/sync reproduce a pinned set |
state / 1rm | derive the profile-state artifact (per-movement 1RM, bodyweight) |
hydrate <file> | percent/bodyweight loads → absolute weights |
dehydrate <file> | absolute weights → percent (a shareable template) |
spec | print the embedded grammar + rule list (offline) |
update [--check] | self-update to the newest release (alias upgrade) |
version | print version + target spec |
man | emit this manual as a roff man page |
help [command] | top-level usage, or per-command detail |
Examples
lint
Two profiles: --strict (default, the authoring lens) and --compat(the import lens — only what the app itself would flag).
$ workoutmd lint routine.workout.md
✓ clean
$ workoutmd lint --no-color broken.workout.md
✗ error prescription/unparsed broken.workout.md:6:25
6 | ## Barbell Bench Press: x8-12
| ^~~~~
movement "Barbell Bench Press" — inline prescription "x8-12" has no
recognizable sets×reps.
fix: Prefix a set count: "3x8-12" (SETSxREPS).
spec: https://docs.tensr.fitness/workout-md/#prescription
1 error, 0 warnings # exit 1
# fail the build on warnings too, from stdin:
cat routine.workout.md | workoutmd lint --json --fail-on warnparse / fmt / from-json
workoutmd parse routine.workout.md # AST as JSON (object; array for many files)
workoutmd fmt --write *.workout.md # canonicalize in place (idempotent)
# round-trip: parse → from-json reproduces fmt(x)
workoutmd parse routine.workout.md | workoutmd from-jsondiff
$ workoutmd diff v1.workout.md v2.workout.md
--- v1.workout.md
+++ v2.workout.md
- Incline Dumbbell Press: 3x8-12 @70% rest:90
+ Cable Fly: 3x12
~ Barbell Bench Press
- 4x6-8 rest:150
+ 4x8 rest:120
1 added, 1 removed, 1 changed # exit 1 (0 when identical)
workoutmd diff --json old.workout.md new.workout.md # { added, removed, changed }share / read-link
The link's wire format (gzip + base64url) is byte-compatible with the app, so it opens the routine in tensr. The default is a terminal QR plus the link — scan it with a phone, no clipboard needed (works over SSH or in a container). read-link is the inverse and never touches the network.
$ workoutmd share --link-only routine.workout.md
https://app.tensr.fitness/#routine=H4sIAAAA...
$ workoutmd share --link-only routine.workout.md | workoutmd read-link
{ "kind": "routine", "payload": { "frontmatter": { … }, "body": { … } } }
# other flags: (default QR) · --no-qr · --copy (pbcopy/xclip/xsel/clip) · --base-urlstate / hydrate / dehydrate
Percent-of-1RM is the portable template form; hydrate joins it to the private numbers you own to produce a personal workout, and dehydrate turns a personal workout back into a shareable template. The state artifact is derived locally from a SQLite export or a routine's defaults block — nothing here reaches the network.
# derive your 1RM/bodyweight state, then hydrate a percent template with it:
workoutmd state --db export.sqlite > me.json
workoutmd hydrate template.workout.md --state me.json --plate 5
# @80% of a 100 kg bench becomes @80kg; a movement with no known 1RM is
# left symbolic with a warning. dehydrate inverts it:
workoutmd dehydrate personal.workout.md --state me.json
# state can also become a shareable #state= link:
workoutmd 1rm --db export.sqlite --linkto (html / pdf / qr / csv)
workoutmd to html routine.workout.md -o routine.html
workoutmd to pdf routine.workout.md -o routine.pdf # pure-Go, no headless browser
workoutmd to qr routine.workout.md
workoutmd to csv routine.workout.md -o routine.csv # inverse of from-csvswap (equipment swaps)
Rewrite a routine to different equipment from curated, deterministic maps — the same swap every time, no model, no network. --to takes an ordered chainof targets — cable (dumbbell→cable), db (cable→dumbbell),suspension (any→suspension trainer) — and for each movement thefirst target with an analog wins, so --to cable,suspension means "prefer cable, else a strap". Two honesty rules: a movement with no analog in any listed target is reported, never invented, and a non-portable load (an absolute weight or a %1RM) is dropped and flagged for re-entry — a dumbbell number doesn't carry onto a cable stack or a strap. Sets/reps/rest/tempo and rpe/bodyweight loads are kept.
$ workoutmd swap --to cable,suspension push-day.workout.md
# rewritten routine → stdout; report → stderr (pipes into lint/fmt):
# swapped 3 movement(s) → cable→suspension; 1 load(s) flagged for re-entry
# ↳ Pushups → Suspension Push-Up (exact) [via suspension]
# ↳ Bent Over Barbell Row → Suspension Row (close) [via suspension]
# 1 unmapped (no analog in cable, suspension):
# ✗ Standing Dumbbell Calf Raise — no cable calf movement in catalog
workoutmd swap --to db routine.workout.md | workoutmd lint - # reverse, then re-lint
workoutmd swap --to cable,suspension --json routine.workout.md # {targets, swapped, unmapped, routine}The maps are the curatedmovement-swap tables(dumbbell⇄cable plus the 349-row suspension scan); every target resolves to a real catalog movement, and the exit status is 1 when a movement was left unmapped (like a lint finding). Swap maps are layered (bundled < installed < workspace < user, with row-level override); swap --explain reports, per swap, which layer/map won and the lower-layer alternatives it shadowed.
maps (installable swap maps)
Fetch a swap map from a URL and keep it locally forever. Install runs afour-stage gate — manifest schema (swap-map/v1), sha256 integrity, parse, and referential integrity (every movement name resolves to a real catalog entry) — before anything lands; a failure at any stage rejects the whole map. Fetching is HTTPS-only with payload / timeout / redirect caps and refuses private/loopback addresses; swapitself never touches the network. A committed maps.lock pins each map's exact version + sha so a whole team reproduces the same swaps.
workoutmd maps install https://maps.example.com/coach-cable.json --catalog bundle.json
workoutmd maps ls # what's installed (tier / version / rows)
workoutmd maps check # any newer versions? (read-only)
workoutmd maps update coach-cable # pull it through the same four-stage gate
workoutmd maps lock # pin versions + shas → .workoutmd/maps.lock (commit it)
workoutmd maps sync # reproduce exactly the pinned setCSV import/export
The spreadsheet front door to a routine: one CSV row is one movement. Draft a routine in a spreadsheet (or a shared Google Sheet), then import it losslessly for the fields it carries. from-csv reads a CSV, to csv writes one (the inverse), and csv-template prints a ready-to-fill starter. Every prescription cell parses with the same grammar the movement line uses, so@70%, {3-1-2-0}, and 2rir mean the same thing in a cell as they do inline.
Starter Google Sheet:(link coming soon — run workoutmd csv-template for the starter columns)
Columns
Only movement is required. Column names match case-insensitively with spaces and underscores ignored (Drop Set = dropset); unknown columns are dropped; a blank cell means unspecified (omitted, not zeroed).
| Column | Maps to | Cell |
|---|---|---|
day | the day heading | free text — Mon, Push A, Day 1 |
cluster | superset tag | A, A1, B2 (no dot) |
movement | name (required) | the exercise name |
modality | modality codes | BB, DB, SA DB |
sets | set count | 3, 3+, 10-12 |
reps | rep count | 5, 8+, 8-12 |
load | resistance | @70%, @50lbs, @rpe9, @BW (@ optional) |
rest | rest seconds | 90, 90s, 2m |
tempo | tempo tuple | {3-1-2-0} (braces optional) |
stop | stop condition | 2rir, failure, amrap 30s |
dropset | drop set | DS, DS-3, DS(2 x 2rir) |
notes | general notes | free text (CSV-quoted if it has commas) |
title | routine title | first non-empty cell wins |
Day grouping: no day anywhere = a single workout; otherwise consecutive rows sharing a day form one day, and a blank dayinherits the row above. Cluster grouping: movements sharing acluster letter-group within a day are a superset. Title:--title, else a title column, else the filename, elseUntitled.
from-csv / to csv / csv-template
# seed a spreadsheet, fill it in, then import it:
workoutmd csv-template > routine.csv
workoutmd from-csv routine.csv # → workout.md on stdout
workoutmd from-csv --json routine.csv | workoutmd lint -
# the round-trip is clean out of the box:
workoutmd csv-template | workoutmd from-csv - | workoutmd lint -
# export any routine to CSV (the inverse), one row per movement:
workoutmd to csv routine.workout.md -o routine.csvfrom-csv --json emits exactly the WorkoutDoc JSON thatparse emits, so it validates against the schema for free. CSV is a flat surface: form/reason notes, images, the defaults block, and meso metadata are not carried — only general notes survive.
The --json contract
workoutmd lint --json emits a stable, deterministic report (2-space indent, trailing newline). Treat its shape as an API.
{
"ok": false,
"spec_version": "0.1",
"profile": "strict",
"diagnostics": [
{
"severity": "error",
"code": "prescription/unparsed",
"file": "routine.workout.md",
"line": 6,
"col": 25,
"span": {
"start": { "line": 6, "col": 25, "byte": 60 },
"end": { "line": 6, "col": 30, "byte": 65 }
},
"message": "movement … inline prescription …",
"fix": "Prefix a set count: \"3x8-12\" (SETSxREPS).",
"spec": "https://docs.tensr.fitness/workout-md/#prescription"
}
]
}ok is true iff diagnostics is empty. file is"<stdin>" when read from stdin. line/colare 1-indexed; the span byte offsets are 0-indexed. Every diagnostic carries a non-empty fix and spec.
Diagnostic codes
| Code | Severity | Profile | Meaning |
|---|---|---|---|
amrap-with-reps | warn | compat | AMRAP combined with a rep count is contradictory. |
defaults-unparsed | warn | compat | a defaults line is not Movement=value. |
mixed-form | warn | compat | a movement mixes an inline prescription with bullet overrides. |
prescription/unparsed | error | strict | an inline tail bound no sets×reps. |
resistance/unrecognized | warn | strict | an @… load token was unrecognized and dropped. |
version/mismatch | warn | strict | frontmatter version: is newer than this build's spec. |
movement/unresolved | warn | strict | a name found no catalog match (only under --resolution local). |
The compat set is exactly what the app emits; --strict adds the rest. Under--compat only the compat codes fire.
Exit codes
| Code | Meaning |
|---|---|
0 | clean — no findings at/above the fail threshold (lint); routines identical (diff); command succeeded. |
1 | findings at/above --fail-on (lint, default error); routines differ (diff). |
2 | usage or I/O error — bad flags, a missing/unreadable file, invalid JSON, an unrecognized link, a PDF aimed at a TTY. |
Resolution modes
--resolution is a config axis parallel to the lint profile. It binds movement names to catalog slugs, entirely offline.
- passthrough (default) — shape only, zero data dependency; names stay as written.
- local — resolve against an offline catalog: a
--catalogbundle (.json/.sqlite) and/or the--dbexport (itscanonical_movement+canonical_aliasrows and your workspace'scustom_movementrows). A miss emits amovement/unresolvedwarning.
Binding uses deterministic tiers only — captured alias, canonical exact, canonical alias, workspace custom. Fuzzy matches are never auto-bound (a false "unresolved" is safe; a wrong binding is not); they are offered only for a human to confirm at a TTY. Under--json, pipes, and CI the resolver never prompts.
workoutmd lint --resolution local --db export.sqlite routine.workout.mdFull per-command flags and more examples: run workoutmd man, orworkoutmd help <command>. The grammar it validates against is theworkout.md spec; the state it hydrates from is described in thedata model.