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.
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.
Once installed, the binary keeps itself current:
workoutmd update --check # is a newer release available?
workoutmd update # download, verify sha256, self-replace
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 |
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> [file] | deterministic equipment swap (cable / db / suspension; comma = ordered fallback); reports unmapped, flags loads |
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).
CSV 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.