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 {