From 14b678e5d2cb643126afa102c0af1f40f47ac4a9 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Sat, 25 Apr 2026 18:17:45 -0400
Subject: [PATCH 1/2] feat: add 'prime' subcommand for LLM agent orientation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per quantcli shared contract §6, every CLI exposes 'prime' printing a
one-screen primer aimed at LLM agents calling the CLI as a tool. Same
section structure across all three repos (WHAT IT IS / OUTPUT FORMATS /
AUTH / DATE FLAGS / SUBCOMMANDS / EXAMPLES / GOTCHAS) so an agent that
has read crono's prime knows where to look in liftoff's.
https://github.com/quantcli/common/blob/main/CONTRACT.md#6-the-prime-subcommand
Liftoff-specific content covers:
- workouts list/show/stats and bodyweights list/stats
- Local-zone date semantics
- LIFTOFF_API_BASE override for the periodic API host rotation
- jq recipes for volume math and bodyweight delta
Also extends the rootCmd Long description so 'liftoff-export --help'
points agents at 'prime' from the top.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
cmd/prime.go | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++
cmd/root.go | 6 +++
2 files changed, 137 insertions(+)
create mode 100644 cmd/prime.go
diff --git a/cmd/prime.go b/cmd/prime.go
new file mode 100644
index 0000000..acddb32
--- /dev/null
+++ b/cmd/prime.go
@@ -0,0 +1,131 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+const primeText = `liftoff-export — primer for LLM agents
+======================================
+
+WHAT IT IS
+ A CLI that reads your personal Liftoff (gymbros.com) data — gym workouts
+ with sets/reps/weights, recorded bodyweights — and prints it on stdout.
+
+OUTPUT FORMATS
+ Default: narrow, fitdown-style markdown — date-grouped headings, one
+ exercise per block with set lines and Nx... compression for repeated
+ sets, easy to skim and easy for an LLM to consume inline.
+
+ --format json Pretty-printed JSON ARRAY of full posts/exercises.
+ Use this when you want the complete row, when piping
+ to jq, or when round-tripping into other tools.
+
+ Errors go to stderr. You do NOT need '2>&1'. Exit code is 0 on
+ success and non-zero on auth or network failure. An empty result is
+ success — markdown prints "No workouts found.", JSON prints '[]'.
+
+AUTH
+ 'liftoff-export auth login' opens an interactive prompt for email/
+ password and writes ~/.config/liftoff-export/auth.json (access token,
+ refresh token, expiry). Subsequent calls auto-refresh when the access
+ token is within 5 minutes of expiry.
+
+ 'liftoff-export auth status' is a fast local check that exits 0 when a
+ saved token is present and not yet expired, 1 with a clear "not logged
+ in" or "token expired" message otherwise. No network call.
+
+ 'liftoff-export auth refresh' forces a refresh now.
+ 'liftoff-export auth logout' deletes the stored tokens.
+
+ Liftoff retires version-pinned API hosts periodically. If a refresh
+ starts failing with "server is deprecated", set LIFTOFF_API_BASE=
+ https://vX-Y-Z.api.getgymbros.com to point at a current version
+ without waiting for a new release.
+
+DATE FLAGS (every export subcommand accepts these)
+ --since VALUE inclusive lower bound
+ --until VALUE inclusive upper bound; defaults to now
+ VALUE: today | yesterday | YYYY-MM-DD | Nd/Nw/Nm/Ny
+
+ See https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags
+ for the cross-CLI specification.
+
+SUBCOMMANDS
+
+ workouts list — every workout you've logged.
+ Markdown: 'Workout MONTH D, YYYY' headings; one exercise block per
+ movement with set lines. Bodyweight-relative sets render as
+ 'reps@-assist' (assisted) or 'reps@+added' (banded).
+ JSON: full Post array. Keys (subset):
+ id, startedAt, postedAt, sessionDuration, sessionNotes,
+ bodyweight, caloriesBurned, prCount,
+ exerciseData: [{ exerciseName, exerciseTypes, setsData: [...] }]
+
+ Filters: --exercise NAME (word-prefix match: 'bench' → 'Bench Press').
+
+ workouts show DATE
+ Same shape as 'list' but only workouts on DATE. DATE is the same
+ vocabulary as --since (today, yesterday, YYYY-MM-DD). Useful for
+ 'what did I do today' agent prompts.
+
+ workouts stats — per-exercise summaries across the window.
+ Markdown: one section per exercise with PR/recent and a per-month
+ bar chart of best weight (or duration for cardio).
+ JSON: array of ExerciseSummary { name, type, sessions: [SessionStats] }.
+ Filters: --exercise, --detail (per-session breakdown).
+
+ bodyweights list — recorded bodyweights.
+ Output: one line per entry, '2026-04-15 187.6 lbs'.
+
+ bodyweights stats — current/high/low, monthly trend chart, plateau
+ detection on the trailing 6 months.
+
+EXAMPLES
+
+ # Today's workout, scannable
+ liftoff-export workouts show today
+
+ # 30-day exercise volume, parsed
+ liftoff-export workouts stats --since 30d --format json | jq '
+ .[] | select(.type == "WR")
+ | { name, total_volume: ([.sessions[].volume] | add) }'
+
+ # PR over time for one exercise
+ liftoff-export workouts stats --exercise bench --since 1y --format json |
+ jq '.[].sessions | map({ date, weight: .bestWeight, reps: .bestReps })'
+
+ # Bodyweight delta vs 90 days ago
+ liftoff-export bodyweights list --since 90d --format json |
+ jq '[.[]] | (.[-1].weight - .[0].weight)'
+
+GOTCHAS
+ - Workout dates are LOCAL. A 11pm workout buckets on the date you
+ logged it, not the UTC date.
+ - Liftoff retires API hosts periodically — see LIFTOFF_API_BASE above.
+ 'liftoff-export auth status' won't catch this; the failure is a
+ deprecation message on the next subcommand call.
+ - Bodyweight is read off Post.bodyweight, which is the value you
+ entered for that workout — not a separate weigh-in feed. No workout
+ that day means no bodyweight that day.
+ - 'workouts stats' silently bins exercises by name. Renaming an
+ exercise in Liftoff splits it into two summaries.
+`
+
+var primeCmd = &cobra.Command{
+ Use: "prime",
+ Short: "Print an LLM-targeted primer (output formats, subcommands, jq recipes)",
+ Long: `Print a one-screen primer aimed at LLM agents calling this CLI as a tool.
+Covers the output formats (markdown by default, --format json for structured),
+auth subcommands and env vars, the subcommands and what their rows look like,
+the shared date flags, and a few jq recipes for common questions.`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ _, err := fmt.Fprint(cmd.OutOrStdout(), primeText)
+ return err
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(primeCmd)
+}
diff --git a/cmd/root.go b/cmd/root.go
index df8b829..a1cdfcb 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -9,6 +9,12 @@ import (
var rootCmd = &cobra.Command{
Use: "liftoff-export",
Short: "CLI for the Liftoff fitness app",
+ Long: `liftoff-export reads your personal Liftoff (gymbros.com) workout and
+bodyweight data and prints it on stdout. Default output is narrow,
+fitdown-style markdown; pass --format json for the full structured row.
+
+LLM agents: run 'liftoff-export prime' for a one-screen orientation
+(I/O contract, subcommands, date flags, jq recipes).`,
}
func Execute() {
From bab4fe3f9c04a135bcff35438dc3a4a0ee162cc5 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Sat, 25 Apr 2026 18:48:46 -0400
Subject: [PATCH 2/2] fix: align JSON output with prime contract
Three small gaps the new 'prime' subcommand surfaced when its examples
were exercised end-to-end:
- 'bodyweights list' had no --format flag at all, but the prime's
bodyweight-delta example pipes 'bodyweights list --since 90d --format
json' into jq. Add --format json (markdown stays default) emitting
[{"date":"YYYY-MM-DD","weight":N}, ...].
- 'workouts stats' printed plain-text "No workouts found." even when the
caller asked for --format json, breaking jq pipelines on empty
windows. Emit '[]' instead.
- printJSON encoded nil slices as 'null'. Match the prime's "JSON is
'[]'" promise by special-casing nil slices via reflect.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
cmd/bodyweights.go | 23 +++++++++++++++++++++++
cmd/stats.go | 3 +++
cmd/workouts.go | 7 +++++++
3 files changed, 33 insertions(+)
diff --git a/cmd/bodyweights.go b/cmd/bodyweights.go
index e57825e..aeb6617 100644
--- a/cmd/bodyweights.go
+++ b/cmd/bodyweights.go
@@ -25,6 +25,7 @@ type monthAvg struct {
var (
bodyweightsListSinceFlag string
bodyweightsListUntilFlag string
+ bodyweightsListFormatFlag string
bodyweightsStatsSinceFlag string
bodyweightsStatsUntilFlag string
)
@@ -34,14 +35,35 @@ var bodyweightsCmd = &cobra.Command{
Short: "Bodyweight commands",
}
+// bodyweightJSON is the on-the-wire shape for `bodyweights list --format json`.
+// Date is the local calendar day the post was logged; weight is in pounds.
+type bodyweightJSON struct {
+ Date string `json:"date"`
+ Weight float64 `json:"weight"`
+}
+
var bodyweightsListCmd = &cobra.Command{
Use: "list",
Short: "List recorded bodyweights",
RunE: func(cmd *cobra.Command, args []string) error {
+ format, err := validateFormat(bodyweightsListFormatFlag)
+ if err != nil {
+ return err
+ }
entries, err := loadBodyweightEntries(bodyweightsListSinceFlag, bodyweightsListUntilFlag)
if err != nil {
return err
}
+ if format == "json" {
+ rows := make([]bodyweightJSON, 0, len(entries))
+ for _, entry := range entries {
+ rows = append(rows, bodyweightJSON{
+ Date: entry.date.Format("2006-01-02"),
+ Weight: entry.weight,
+ })
+ }
+ return printJSON(rows)
+ }
if len(entries) == 0 {
fmt.Println("No bodyweights found.")
return nil
@@ -78,6 +100,7 @@ func init() {
bodyweightsListCmd.Flags().StringVar(&bodyweightsListSinceFlag, "since", "", "Filter entries on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)")
bodyweightsListCmd.Flags().StringVar(&bodyweightsListUntilFlag, "until", "", "Filter entries through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)")
+ bodyweightsListCmd.Flags().StringVar(&bodyweightsListFormatFlag, "format", "markdown", "Output format: markdown (default) or json")
bodyweightsStatsCmd.Flags().StringVar(&bodyweightsStatsSinceFlag, "since", "", "Filter entries on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)")
bodyweightsStatsCmd.Flags().StringVar(&bodyweightsStatsUntilFlag, "until", "", "Filter entries through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)")
}
diff --git a/cmd/stats.go b/cmd/stats.go
index 87ad36e..699a950 100644
--- a/cmd/stats.go
+++ b/cmd/stats.go
@@ -65,6 +65,9 @@ var statsCmd = &cobra.Command{
posts = filterExercises(posts, statsExerciseFlag)
}
if len(posts) == 0 {
+ if format == "json" {
+ return printJSON([]ExerciseSummary{})
+ }
fmt.Println("No workouts found.")
return nil
}
diff --git a/cmd/workouts.go b/cmd/workouts.go
index 9db8f97..e15d260 100644
--- a/cmd/workouts.go
+++ b/cmd/workouts.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
+ "reflect"
"strings"
"time"
@@ -344,6 +345,12 @@ func printFitdown(posts []Post) error {
}
func printJSON(v any) error {
+ // A nil slice marshals as "null"; force "[]" so empty windows match the
+ // prime contract (and don't blow up jq pipelines).
+ if rv := reflect.ValueOf(v); rv.Kind() == reflect.Slice && rv.IsNil() {
+ _, err := os.Stdout.WriteString("[]\n")
+ return err
+ }
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {