From c1a4879b56d4a119cc97eda4d8a5a1cf9b111ef8 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Sat, 25 Apr 2026 17:16:06 -0400
Subject: [PATCH] feat: markdown by default, --json for structured output
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per a feedback session: agents calling this CLI as a tool tend to do
better with narrow markdown they can read inline than with raw JSON.
Switch the default output to fitdown-style markdown — date-grouped
headings, one bullet per non-zero field, no wide tables — and add
--json (or --format json) for the structured shape that pipes well to
jq.
Layout per endpoint:
servings ## per date, ### per food (group · name · qty), bullets
for non-zero nutrients (uses reflection over the typed
struct, splits CamelCase suffixes for units).
nutrition ## per date, bullets for non-zero columns; alpha sort.
biometrics ## per date, bullet per metric (Metric: amount unit).
exercises ## per date, single line per session.
notes ## per date with note body; falls back to dumping all
non-empty fields if a Note column isn't found.
Markdown drops zero-valued nutrients to keep output scannable; the
trailer points users at --json for the full row.
Wired through a small format.go (AddFormatFlags + emit + per-kind
renderers) and updated each subcommand to call emit(cmd, kind, v).
prime, root --help, and the README were all updated to describe the
new contract.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
README.md | 57 ++++---
cmd/biometrics.go | 3 +-
cmd/exercises.go | 3 +-
cmd/format.go | 375 ++++++++++++++++++++++++++++++++++++++++++++++
cmd/notes.go | 3 +-
cmd/nutrition.go | 3 +-
cmd/prime.go | 75 ++++++----
cmd/root.go | 18 +--
cmd/servings.go | 3 +-
9 files changed, 462 insertions(+), 78 deletions(-)
create mode 100644 cmd/format.go
diff --git a/README.md b/README.md
index e443a72..56ccdd6 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# crono-export-cli
-Export your personal nutrition, biometric, and food-log data from [Cronometer](https://cronometer.com) as JSON. Built for personal LLM agents and scripts that want structured nutrition data — for example, an LLM-driven bariatric or fitness coach that needs to know how much protein, B12, iron, or calcium you actually got today.
+Export your personal nutrition, biometric, and food-log data from [Cronometer](https://cronometer.com) as narrow markdown (the default) or JSON. Built for personal LLM agents and scripts that want structured nutrition data — for example, an LLM-driven bariatric or fitness coach that needs to know how much protein, B12, iron, or calcium you actually got today.
[](https://github.com/quantcli/crono-export-cli/releases/latest)
[](LICENSE)
@@ -10,7 +10,7 @@ Export your personal nutrition, biometric, and food-log data from [Cronometer](h
## Features
- **Five export endpoints** — servings (per-food log with full nutrient breakdown), nutrition (daily totals), biometrics (weight, body fat, custom metrics), exercises, and notes
-- **JSON on stdout** — pipe straight to `jq`, save to disk, or hand to an LLM tool
+- **Markdown by default, JSON on demand** — narrow fitdown-style markdown reads well in chat and terminals; pass `--json` for the full structured row to pipe through `jq`
- **Date selection** — `--today`, `--days N`, or `--start YYYY-MM-DD --end YYYY-MM-DD` on every subcommand
- **Single static binary** — no Python or Node runtime; drop it in `~/bin/` and go
- **Credentials via env** — `CRONOMETER_USERNAME` / `CRONOMETER_PASSWORD`, no config file needed
@@ -101,24 +101,20 @@ crono-export servings --days 7
crono-export servings --start 2026-04-01 --end 2026-04-15
```
-Sample row (truncated):
-
-```json
-{
- "RecordedTime": "2026-04-11T00:00:00Z",
- "Group": "Breakfast",
- "FoodName": "Cheese Cracker",
- "QuantityValue": 20,
- "QuantityUnits": "square",
- "EnergyKcal": 97.8,
- "ProteinG": 1.95,
- "CarbsG": 11.88,
- "FiberG": 0.46,
- "FatG": 4.94,
- "B12Mg": 0.07,
- "CalciumMg": 27.2,
- "IronMg": 0.69
-}
+Default markdown output (per food, zero-valued nutrients suppressed):
+
+```markdown
+## 2026-04-11
+
+### Breakfast · Cheese Cracker (20 square)
+- Energy: 97.8 kcal
+- Protein: 1.95 g
+- Carbs: 11.88 g
+- Fiber: 0.46 g
+- Fat: 4.94 g
+- B12: 0.07 mg
+- Calcium: 27.2 mg
+- Iron: 0.69 mg
```
### Nutrition — daily totals
@@ -135,15 +131,9 @@ crono-export nutrition --days 30
crono-export biometrics --days 30
```
-```json
-[
- {
- "RecordedTime": "2026-04-10T00:00:00Z",
- "Metric": "Weight",
- "Unit": "lbs",
- "Amount": 237
- }
-]
+```markdown
+## 2026-04-10
+- Weight: 237 lbs
```
### Exercises
@@ -160,12 +150,17 @@ crono-export notes --days 30
## Output Format
-Every subcommand prints pretty-printed JSON to stdout. Errors and progress messages go to stderr, so it's safe to pipe stdout into `jq`, redirect into a file, or feed to an LLM tool without worrying about mixed output.
+Default output is narrow, [Fitdown](https://github.com/datavis-tech/fitdown)-style markdown — date-grouped headings, one bullet per non-zero field, no wide tables. Markdown reads well in chat and on a terminal and is easy for an LLM to consume inline.
+
+For programmatic use, pass `--json` (or `--format json`) to get the full structured row as a JSON array on stdout — nothing suppressed, easy to pipe through `jq`. Errors always go to stderr, so JSON output stays clean for piping.
```sh
-crono-export servings --today | jq '[.[] | {food: .FoodName, protein: .ProteinG}]'
+crono-export servings --today # markdown, default
+crono-export servings --today --json | jq '[.[] | {food: .FoodName, protein: .ProteinG}]'
```
+LLM agents: run `crono-export prime` for a one-screen orientation describing both formats, all subcommands, the date flags, and `jq` recipes.
+
## About Cronometer
[Cronometer](https://cronometer.com) is a nutrition tracking app with one of the best micronutrient databases of any consumer tool — a major reason it's commonly recommended for bariatric patients, anyone tracking specific vitamin/mineral targets, or athletes managing recovery nutrition.
diff --git a/cmd/biometrics.go b/cmd/biometrics.go
index 44dda76..cc5bf6b 100644
--- a/cmd/biometrics.go
+++ b/cmd/biometrics.go
@@ -24,11 +24,12 @@ var biometricsCmd = &cobra.Command{
if err != nil {
return err
}
- return emitJSON(recs)
+ return emit(cmd, kindBiometrics, recs)
},
}
func init() {
cronoclient.AddDateRangeFlags(biometricsCmd)
+ AddFormatFlags(biometricsCmd)
rootCmd.AddCommand(biometricsCmd)
}
diff --git a/cmd/exercises.go b/cmd/exercises.go
index d68288c..9013a7b 100644
--- a/cmd/exercises.go
+++ b/cmd/exercises.go
@@ -24,11 +24,12 @@ var exercisesCmd = &cobra.Command{
if err != nil {
return err
}
- return emitJSON(recs)
+ return emit(cmd, kindExercises, recs)
},
}
func init() {
cronoclient.AddDateRangeFlags(exercisesCmd)
+ AddFormatFlags(exercisesCmd)
rootCmd.AddCommand(exercisesCmd)
}
diff --git a/cmd/format.go b/cmd/format.go
new file mode 100644
index 0000000..c5ad521
--- /dev/null
+++ b/cmd/format.go
@@ -0,0 +1,375 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "reflect"
+ "sort"
+ "strings"
+
+ "github.com/jrmycanady/gocronometer"
+ "github.com/spf13/cobra"
+)
+
+type recordKind int
+
+const (
+ kindServings recordKind = iota
+ kindNutrition
+ kindBiometrics
+ kindExercises
+ kindNotes
+)
+
+// AddFormatFlags registers --format and --json on every export subcommand.
+func AddFormatFlags(cmd *cobra.Command) {
+ cmd.Flags().String("format", "md", "output format: md|json")
+ cmd.Flags().Bool("json", false, "shortcut for --format json")
+}
+
+func chosenFormat(cmd *cobra.Command) (string, error) {
+ f, _ := cmd.Flags().GetString("format")
+ if j, _ := cmd.Flags().GetBool("json"); j {
+ f = "json"
+ }
+ switch f {
+ case "md", "markdown":
+ return "md", nil
+ case "json":
+ return "json", nil
+ default:
+ return "", fmt.Errorf("unknown --format %q (want md or json)", f)
+ }
+}
+
+// emit writes v in the format chosen on cmd. kind tells the markdown
+// renderer which layout to use.
+func emit(cmd *cobra.Command, kind recordKind, v any) error {
+ f, err := chosenFormat(cmd)
+ if err != nil {
+ return err
+ }
+ if f == "json" {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(v)
+ }
+ return renderMarkdown(os.Stdout, kind, v)
+}
+
+func renderMarkdown(w io.Writer, kind recordKind, v any) error {
+ switch kind {
+ case kindServings:
+ recs, _ := v.(gocronometer.ServingRecords)
+ return renderServings(w, recs)
+ case kindBiometrics:
+ recs, _ := v.(gocronometer.BiometricRecords)
+ return renderBiometrics(w, recs)
+ case kindExercises:
+ recs, _ := v.(gocronometer.ExerciseRecords)
+ return renderExercises(w, recs)
+ case kindNutrition:
+ rows, _ := v.([]map[string]string)
+ return renderNutrition(w, rows)
+ case kindNotes:
+ rows, _ := v.([]map[string]string)
+ return renderNotes(w, rows)
+ }
+ return fmt.Errorf("renderMarkdown: unknown kind %d", kind)
+}
+
+// ---- shared helpers ---------------------------------------------------
+
+func emptyMsg(w io.Writer) error {
+ _, err := fmt.Fprintln(w, "_(no records in window)_")
+ return err
+}
+
+// fmtFloat trims trailing zeros so 1.95 → "1.95" and 100.000 → "100".
+func fmtFloat(f float64) string {
+ s := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", f), "0"), ".")
+ if s == "" || s == "-" {
+ return "0"
+ }
+ return s
+}
+
+// strippedSuffix splits a CamelCase Go field name like "EnergyKcal" or
+// "B12Mg" into ("Energy","kcal") or ("B12","mg"). Order matters: longer
+// suffixes first so we don't strip "g" out of "Mg".
+func strippedSuffix(field string) (name, unit string) {
+ for _, suf := range []struct{ go_, display string }{
+ {"Kcal", "kcal"},
+ {"Mg", "mg"},
+ {"Ug", "µg"},
+ {"UI", "IU"},
+ {"G", "g"},
+ } {
+ if strings.HasSuffix(field, suf.go_) && len(field) > len(suf.go_) {
+ return field[:len(field)-len(suf.go_)], suf.display
+ }
+ }
+ return field, ""
+}
+
+// ---- servings ---------------------------------------------------------
+
+func renderServings(w io.Writer, recs gocronometer.ServingRecords) error {
+ if len(recs) == 0 {
+ return emptyMsg(w)
+ }
+ // Group by local calendar date.
+ byDate := map[string][]gocronometer.ServingRecord{}
+ for _, r := range recs {
+ d := r.RecordedTime.Format("2006-01-02")
+ byDate[d] = append(byDate[d], r)
+ }
+ dates := make([]string, 0, len(byDate))
+ for d := range byDate {
+ dates = append(dates, d)
+ }
+ sort.Strings(dates)
+
+ for di, d := range dates {
+ if di > 0 {
+ fmt.Fprintln(w)
+ }
+ fmt.Fprintf(w, "## %s\n\n", d)
+ for _, r := range byDate[d] {
+ renderServingRecord(w, r)
+ }
+ }
+ fmt.Fprintln(w, "_zero-valued nutrients omitted; use --json for the full row_")
+ return nil
+}
+
+func renderServingRecord(w io.Writer, r gocronometer.ServingRecord) {
+ header := fmt.Sprintf("### %s · %s", strDefault(r.Group, "—"), r.FoodName)
+ if r.QuantityValue != 0 || r.QuantityUnits != "" {
+ header += fmt.Sprintf(" (%s %s)", fmtFloat(r.QuantityValue), r.QuantityUnits)
+ }
+ fmt.Fprintln(w, header)
+
+ v := reflect.ValueOf(r)
+ t := v.Type()
+ skip := map[string]bool{
+ "RecordedTime": true,
+ "Group": true,
+ "FoodName": true,
+ "QuantityValue": true,
+ "QuantityUnits": true,
+ "Category": true,
+ }
+ for i := 0; i < t.NumField(); i++ {
+ fname := t.Field(i).Name
+ if skip[fname] {
+ continue
+ }
+ if v.Field(i).Kind() != reflect.Float64 {
+ continue
+ }
+ val := v.Field(i).Float()
+ if val == 0 {
+ continue
+ }
+ name, unit := strippedSuffix(fname)
+ if unit != "" {
+ fmt.Fprintf(w, "- %s: %s %s\n", name, fmtFloat(val), unit)
+ } else {
+ fmt.Fprintf(w, "- %s: %s\n", name, fmtFloat(val))
+ }
+ }
+ fmt.Fprintln(w)
+}
+
+func strDefault(s, fallback string) string {
+ if s == "" {
+ return fallback
+ }
+ return s
+}
+
+// ---- biometrics -------------------------------------------------------
+
+func renderBiometrics(w io.Writer, recs gocronometer.BiometricRecords) error {
+ if len(recs) == 0 {
+ return emptyMsg(w)
+ }
+ byDate := map[string][]gocronometer.BiometricRecord{}
+ for _, r := range recs {
+ d := r.RecordedTime.Format("2006-01-02")
+ byDate[d] = append(byDate[d], r)
+ }
+ dates := make([]string, 0, len(byDate))
+ for d := range byDate {
+ dates = append(dates, d)
+ }
+ sort.Strings(dates)
+ for di, d := range dates {
+ if di > 0 {
+ fmt.Fprintln(w)
+ }
+ fmt.Fprintf(w, "## %s\n", d)
+ for _, r := range byDate[d] {
+ unit := r.Unit
+ if unit != "" {
+ unit = " " + unit
+ }
+ fmt.Fprintf(w, "- %s: %s%s\n", r.Metric, fmtFloat(r.Amount), unit)
+ }
+ }
+ return nil
+}
+
+// ---- exercises --------------------------------------------------------
+
+func renderExercises(w io.Writer, recs gocronometer.ExerciseRecords) error {
+ if len(recs) == 0 {
+ return emptyMsg(w)
+ }
+ byDate := map[string][]gocronometer.ExerciseRecord{}
+ for _, r := range recs {
+ d := r.RecordedTime.Format("2006-01-02")
+ byDate[d] = append(byDate[d], r)
+ }
+ dates := make([]string, 0, len(byDate))
+ for d := range byDate {
+ dates = append(dates, d)
+ }
+ sort.Strings(dates)
+ for di, d := range dates {
+ if di > 0 {
+ fmt.Fprintln(w)
+ }
+ fmt.Fprintf(w, "## %s\n", d)
+ for _, r := range byDate[d] {
+ parts := []string{r.Exercise}
+ if r.Minutes != 0 {
+ parts = append(parts, fmtFloat(r.Minutes)+" min")
+ }
+ if r.CaloriesBurned != 0 {
+ parts = append(parts, fmtFloat(r.CaloriesBurned)+" kcal")
+ }
+ line := strings.Join(parts, ", ")
+ if r.Group != "" {
+ line += fmt.Sprintf(" (%s)", r.Group)
+ }
+ fmt.Fprintf(w, "- %s\n", line)
+ }
+ }
+ return nil
+}
+
+// ---- nutrition (daily totals, string-keyed CSV) ----------------------
+
+func renderNutrition(w io.Writer, rows []map[string]string) error {
+ if len(rows) == 0 {
+ return emptyMsg(w)
+ }
+ // Sort by Date asc.
+ sort.SliceStable(rows, func(i, j int) bool {
+ return rows[i]["Date"] < rows[j]["Date"]
+ })
+ for di, row := range rows {
+ if di > 0 {
+ fmt.Fprintln(w)
+ }
+ date := row["Date"]
+ if date == "" {
+ date = "(unknown date)"
+ }
+ fmt.Fprintf(w, "## %s\n", date)
+
+ keys := make([]string, 0, len(row))
+ for k := range row {
+ if k == "Date" {
+ continue
+ }
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ v := row[k]
+ if isZeroish(v) {
+ continue
+ }
+ fmt.Fprintf(w, "- %s: %s\n", k, v)
+ }
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "_zero-valued nutrients omitted; use --json for the full row_")
+ return nil
+}
+
+// isZeroish reports whether a CSV value should be treated as "no data" and
+// hidden from the markdown output. Empty strings, "0", "0.0", "0.00", etc.
+// are zeroish; everything else (including "false", "true", arbitrary text)
+// is rendered.
+func isZeroish(s string) bool {
+ if s == "" {
+ return true
+ }
+ // Try numeric: if it parses to 0, it's zeroish.
+ var f float64
+ if _, err := fmt.Sscanf(s, "%f", &f); err == nil && f == 0 {
+ // But only if the entire string was numeric.
+ t := strings.TrimSpace(s)
+ for _, r := range t {
+ if !(r >= '0' && r <= '9') && r != '.' && r != '-' && r != '+' {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
+// ---- notes ------------------------------------------------------------
+
+func renderNotes(w io.Writer, rows []map[string]string) error {
+ if len(rows) == 0 {
+ return emptyMsg(w)
+ }
+ dateKey := pickKey(rows[0], "Day", "Date")
+ noteKey := pickKey(rows[0], "Note", "Notes", "Comment")
+ timeKey := pickKey(rows[0], "Time")
+
+ for di, row := range rows {
+ if di > 0 {
+ fmt.Fprintln(w)
+ }
+ date := row[dateKey]
+ if date == "" {
+ date = "(unknown date)"
+ }
+ header := "## " + date
+ if t := row[timeKey]; t != "" {
+ header += " " + t
+ }
+ fmt.Fprintln(w, header)
+ if note := strings.TrimSpace(row[noteKey]); note != "" {
+ fmt.Fprintln(w, note)
+ } else {
+ // Fall back to dumping all non-empty fields if we can't find a Note column.
+ for k, v := range row {
+ if k == dateKey || k == timeKey || v == "" {
+ continue
+ }
+ fmt.Fprintf(w, "- %s: %s\n", k, v)
+ }
+ }
+ }
+ return nil
+}
+
+func pickKey(row map[string]string, candidates ...string) string {
+ for _, c := range candidates {
+ if _, ok := row[c]; ok {
+ return c
+ }
+ }
+ return ""
+}
+
diff --git a/cmd/notes.go b/cmd/notes.go
index 5e91bbd..734ac84 100644
--- a/cmd/notes.go
+++ b/cmd/notes.go
@@ -24,11 +24,12 @@ var notesCmd = &cobra.Command{
if err != nil {
return err
}
- return emitJSON(rows)
+ return emit(cmd, kindNotes, rows)
},
}
func init() {
cronoclient.AddDateRangeFlags(notesCmd)
+ AddFormatFlags(notesCmd)
rootCmd.AddCommand(notesCmd)
}
diff --git a/cmd/nutrition.go b/cmd/nutrition.go
index 39b5b6a..68ecb9e 100644
--- a/cmd/nutrition.go
+++ b/cmd/nutrition.go
@@ -24,11 +24,12 @@ var nutritionCmd = &cobra.Command{
if err != nil {
return err
}
- return emitJSON(rows)
+ return emit(cmd, kindNutrition, rows)
},
}
func init() {
cronoclient.AddDateRangeFlags(nutritionCmd)
+ AddFormatFlags(nutritionCmd)
rootCmd.AddCommand(nutritionCmd)
}
diff --git a/cmd/prime.go b/cmd/prime.go
index cf533f0..07506d2 100644
--- a/cmd/prime.go
+++ b/cmd/prime.go
@@ -11,15 +11,21 @@ const primeText = `crono-export — primer for LLM agents
WHAT IT IS
A CLI that reads your personal Cronometer data (per-food log, daily totals,
- weight/biometrics, exercises, notes) and prints it as JSON on stdout.
+ weight/biometrics, exercises, notes) and prints it on stdout.
-I/O CONTRACT
- - Output: pretty-printed JSON ARRAY on stdout. THIS IS THE ONLY MODE.
- There is no --json / --format flag; JSON is always the format.
- - Errors: human-readable text on stderr. You do NOT need '2>&1'.
- - Empty result '[]' = success with zero rows in the window, not an error.
- - Exit code: 0 success, non-zero on auth or network failure.
- - Filter with jq. Don't pipe to python — output is already JSON.
+OUTPUT FORMATS
+ Default: narrow, fitdown-style markdown — date-grouped headings, one
+ bullet per non-zero field, easy to skim and easy for an LLM to consume
+ inline. Zero-valued nutrients are suppressed in markdown.
+
+ --json (or --format json) Pretty-printed JSON ARRAY of full rows.
+ Use this when you want the complete row,
+ when piping to jq, or when round-tripping
+ into other tools. Nothing is suppressed.
+
+ 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 records in window)_", JSON prints '[]'.
AUTH
Set both env vars before invoking. No config file or token cache; the CLI
@@ -36,62 +42,71 @@ DATE FLAGS (every export subcommand accepts these)
SUBCOMMANDS
servings — per-food log: one row per food eaten, full nutrient breakdown.
- Typed numbers. Keys (subset):
+ Markdown: ## per date, ### per food (group · name · quantity), bullets
+ for non-zero nutrients.
+ JSON: typed numbers. Keys (subset):
RecordedTime, Group, FoodName, QuantityValue, QuantityUnits,
EnergyKcal, ProteinG, CarbsG, FiberG, FatG, SodiumMg, CalciumMg,
- IronMg, B12Mg, VitaminDUI, Omega3G, Omega6G, ... (60+ nutrients).
+ IronMg, B12Mg, VitaminDUI, Omega3G, ... (60+ nutrients).
nutrition — daily totals: one row per day across all foods logged that day.
- String-keyed (raw CSV columns, ALL VALUES ARE STRINGS — cast in jq).
+ Markdown: ## per date, bullets for non-zero columns.
+ JSON: string-keyed (raw CSV columns, ALL VALUES ARE STRINGS — cast in jq).
Keys (subset):
"Date", "Energy (kcal)", "Protein (g)", "Carbs (g)", "Fat (g)",
"Fiber (g)", "Sodium (mg)", "Iron (mg)", "Calcium (mg)",
"B12 (Cobalamin) (µg)", "Cholesterol (mg)", "Completed", ...
biometrics — weight, body fat, blood pressure, custom metrics.
- Typed. Keys: RecordedTime, Metric, Unit, Amount.
+ Markdown: ## per date, bullet per metric: "- Metric: amount unit".
+ JSON keys: RecordedTime, Metric, Unit, Amount.
exercises — logged cardio / strength / custom activities.
- Typed. Keys: RecordedTime, Exercise, Minutes, CaloriesBurned, Group.
+ Markdown: ## per date, one bullet per session.
+ JSON keys: RecordedTime, Exercise, Minutes, CaloriesBurned, Group.
- notes — user-entered notes per day. String-keyed (raw CSV).
+ notes — user-entered notes per day. Markdown: ## per date with note
+ body. JSON: string-keyed (raw CSV).
EXAMPLES
- # Today's macros, as numbers
- crono-export nutrition --today | jq '.[] | {
+ # Today's macros, scannable
+ crono-export nutrition --today
+
+ # Today's macros, parsed (numbers via tonumber)
+ crono-export nutrition --today --json | jq '.[] | {
date: .Date,
kcal: (."Energy (kcal)" | tonumber),
- protein: (."Protein (g)" | tonumber),
- carbs: (."Carbs (g)" | tonumber),
- fat: (."Fat (g)" | tonumber)
+ protein: (."Protein (g)" | tonumber)
}'
# 7-day protein total (servings is typed — no tonumber needed)
- crono-export servings --days 7 | jq '[.[] | .ProteinG] | add'
+ crono-export servings --days 7 --json | jq '[.[] | .ProteinG] | add'
# All foods from today's breakfast
- crono-export servings --today | jq '[.[] | select(.Group == "Breakfast") | .FoodName]'
+ crono-export servings --today --json | jq '[.[] | select(.Group == "Breakfast") | .FoodName]'
# Latest weight reading in a 30-day window
- crono-export biometrics --days 30 | jq 'map(select(.Metric == "Weight")) | sort_by(.RecordedTime) | last'
+ crono-export biometrics --days 30 --json | jq 'map(select(.Metric == "Weight")) | sort_by(.RecordedTime) | last'
GOTCHAS
- "Today" is your LOCAL calendar day, not UTC.
- - 'nutrition' and 'notes' values are STRINGS (raw CSV) — cast with
- 'jq tonumber' when doing math. 'servings', 'biometrics', 'exercises'
- are already typed numbers.
- - Cronometer logs by calendar day; nothing here is real-time. The same
+ - 'nutrition' and 'notes' JSON values are STRINGS (raw CSV) — cast with
+ 'jq tonumber' when doing math. 'servings', 'biometrics', 'exercises'
+ JSON values are already typed numbers.
+ - Markdown drops zero-valued nutrients to stay readable. If you need
+ every column (including zeros), use --json.
+ - Cronometer logs by calendar day; nothing here is real-time. The same
--today call moments apart returns the same data.
`
var primeCmd = &cobra.Command{
Use: "prime",
- Short: "Print an LLM-targeted primer (I/O contract, subcommands, jq recipes)",
+ 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 contract (JSON-on-stdout, no --json flag), auth env vars,
-the subcommands and what their rows look like, the shared date flags, and a
-few jq recipes for common questions.`,
+Covers the output formats (markdown by default, --json for structured),
+auth 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
diff --git a/cmd/root.go b/cmd/root.go
index 06947ff..6c81783 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -2,7 +2,6 @@
package cmd
import (
- "encoding/json"
"fmt"
"os"
@@ -11,16 +10,18 @@ import (
var rootCmd = &cobra.Command{
Use: "crono-export",
- Short: "Export Cronometer nutrition, biometrics, and food log data as JSON",
+ Short: "Export Cronometer nutrition, biometrics, and food log data",
Long: `crono-export reads your personal Cronometer data via the same export
-endpoints the web app uses and prints it as JSON on stdout.
+endpoints the web app uses and prints it on stdout. Default output is
+narrow, fitdown-style markdown; pass --json (or --format json) for the
+full structured row.
Credentials must be supplied via environment variables:
CRONOMETER_USERNAME your Cronometer email
CRONOMETER_PASSWORD your Cronometer password
-Designed for use by personal LLM agents and scripts that want structured
-nutrition data — for example, an LLM-driven bariatric or fitness coach.
+Designed for use by personal LLM agents and scripts — markdown reads
+well in chat, JSON pipes well to jq.
LLM agents: run 'crono-export prime' for a one-screen orientation
(I/O contract, subcommands, date flags, jq recipes).`,
@@ -35,10 +36,3 @@ func Execute() {
os.Exit(1)
}
}
-
-// emitJSON pretty-prints v as JSON to stdout. Used by every subcommand.
-func emitJSON(v any) error {
- enc := json.NewEncoder(os.Stdout)
- enc.SetIndent("", " ")
- return enc.Encode(v)
-}
diff --git a/cmd/servings.go b/cmd/servings.go
index fb51ccb..605e1e4 100644
--- a/cmd/servings.go
+++ b/cmd/servings.go
@@ -24,11 +24,12 @@ var servingsCmd = &cobra.Command{
if err != nil {
return err
}
- return emitJSON(recs)
+ return emit(cmd, kindServings, recs)
},
}
func init() {
cronoclient.AddDateRangeFlags(servingsCmd)
+ AddFormatFlags(servingsCmd)
rootCmd.AddCommand(servingsCmd)
}