tensr.fitness

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 | sh

It 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
done

Commands

Reads file arguments or stdin; human output to stdout, errors to stderr.

CommandDoes
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-templateemit 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 / 1rmderive the profile-state artifact (per-movement 1RM, bodyweight)
hydrate <file>percent/bodyweight loads → absolute weights
dehydrate <file>absolute weights → percent (a shareable template)
specprint the embedded grammar + rule list (offline)
update [--check]self-update to the newest release (alias upgrade)
versionprint version + target spec
manemit 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 warn

parse / 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-json

diff

$ 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-url

state / 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 --link

to (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-csv

swap (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).

ColumnMaps toCell
daythe day headingfree text — Mon, Push A, Day 1
clustersuperset tagA, A1, B2 (no dot)
movementname (required)the exercise name
modalitymodality codesBB, DB, SA DB
setsset count3, 3+, 10-12
repsrep count5, 8+, 8-12
loadresistance@70%, @50lbs, @rpe9, @BW (@ optional)
restrest seconds90, 90s, 2m
tempotempo tuple{3-1-2-0} (braces optional)
stopstop condition2rir, failure, amrap 30s
dropsetdrop setDS, DS-3, DS(2 x 2rir)
notesgeneral notesfree text (CSV-quoted if it has commas)
titleroutine titlefirst 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.csv

from-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

CodeSeverityProfileMeaning
amrap-with-repswarncompatAMRAP combined with a rep count is contradictory.
defaults-unparsedwarncompata defaults line is not Movement=value.
mixed-formwarncompata movement mixes an inline prescription with bullet overrides.
prescription/unparsederrorstrictan inline tail bound no sets×reps.
resistance/unrecognizedwarnstrictan @… load token was unrecognized and dropped.
version/mismatchwarnstrictfrontmatter version: is newer than this build's spec.
movement/unresolvedwarnstricta 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

CodeMeaning
0clean — no findings at/above the fail threshold (lint); routines identical (diff); command succeeded.
1findings at/above --fail-on (lint, default error); routines differ (diff).
2usage 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.

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.md

Full 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.