Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,10 @@ LLM agents: run `crono-export prime` for a one-screen orientation describing bot

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

This CLI is an unofficial tool for exporting your own data. It uses the same web export endpoints the Cronometer SPA uses, via [`jrmycanady/gocronometer`](https://github.com/jrmycanady/gocronometer). It is intended for personal single-user use only — see the upstream library's notes on appropriate use.
This CLI is an unofficial tool for exporting your own data. It speaks directly to the same web export endpoints the Cronometer SPA uses, via an MIT-licensed in-tree HTTP client (`internal/cronoapi`). It is intended for personal single-user use only.

## License

MIT — see [LICENSE](LICENSE).

The underlying [`gocronometer`](https://github.com/jrmycanady/gocronometer) library is GPLv2-licensed.
The CLI is MIT-clean: it has no transitive GPL dependencies. See [LICENSING.md](LICENSING.md) for the history.
22 changes: 11 additions & 11 deletions cmd/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"sort"
"strings"

"github.com/jrmycanady/gocronometer"
"github.com/quantcli/crono-export-cli/internal/cronoapi"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -61,13 +61,13 @@ func emit(cmd *cobra.Command, kind recordKind, v any) error {
func renderMarkdown(w io.Writer, kind recordKind, v any) error {
switch kind {
case kindServings:
recs, _ := v.(gocronometer.ServingRecords)
recs, _ := v.(cronoapi.ServingRecords)
return renderServings(w, recs)
case kindBiometrics:
recs, _ := v.(gocronometer.BiometricRecords)
recs, _ := v.(cronoapi.BiometricRecords)
return renderBiometrics(w, recs)
case kindExercises:
recs, _ := v.(gocronometer.ExerciseRecords)
recs, _ := v.(cronoapi.ExerciseRecords)
return renderExercises(w, recs)
case kindNutrition:
rows, _ := v.([]map[string]string)
Expand Down Expand Up @@ -115,12 +115,12 @@ func strippedSuffix(field string) (name, unit string) {

// ---- servings ---------------------------------------------------------

func renderServings(w io.Writer, recs gocronometer.ServingRecords) error {
func renderServings(w io.Writer, recs cronoapi.ServingRecords) error {
if len(recs) == 0 {
return emptyMsg(w)
}
// Group by local calendar date.
byDate := map[string][]gocronometer.ServingRecord{}
byDate := map[string][]cronoapi.ServingRecord{}
for _, r := range recs {
d := r.RecordedTime.Format("2006-01-02")
byDate[d] = append(byDate[d], r)
Expand All @@ -144,7 +144,7 @@ func renderServings(w io.Writer, recs gocronometer.ServingRecords) error {
return nil
}

func renderServingRecord(w io.Writer, r gocronometer.ServingRecord) {
func renderServingRecord(w io.Writer, r cronoapi.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)
Expand Down Expand Up @@ -192,11 +192,11 @@ func strDefault(s, fallback string) string {

// ---- biometrics -------------------------------------------------------

func renderBiometrics(w io.Writer, recs gocronometer.BiometricRecords) error {
func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {
if len(recs) == 0 {
return emptyMsg(w)
}
byDate := map[string][]gocronometer.BiometricRecord{}
byDate := map[string][]cronoapi.BiometricRecord{}
for _, r := range recs {
d := r.RecordedTime.Format("2006-01-02")
byDate[d] = append(byDate[d], r)
Expand Down Expand Up @@ -224,11 +224,11 @@ func renderBiometrics(w io.Writer, recs gocronometer.BiometricRecords) error {

// ---- exercises --------------------------------------------------------

func renderExercises(w io.Writer, recs gocronometer.ExerciseRecords) error {
func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {
if len(recs) == 0 {
return emptyMsg(w)
}
byDate := map[string][]gocronometer.ExerciseRecord{}
byDate := map[string][]cronoapi.ExerciseRecord{}
for _, r := range recs {
d := r.RecordedTime.Format("2006-01-02")
byDate[d] = append(byDate[d], r)
Expand Down
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ module github.com/quantcli/crono-export-cli
go 1.25.10

require (
github.com/jrmycanady/gocronometer v1.5.1
github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b
github.com/spf13/cobra v1.10.2
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.46.0 // indirect
)
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jrmycanady/gocronometer v1.5.1 h1:m2J31jEuLlL4RRdQLY33IFs4TAwmfevvJYl2SZxBSQ0=
github.com/jrmycanady/gocronometer v1.5.1/go.mod h1:swnvYB6twU20LDzNpAz8JOX5mCHktTW06zlSXmmyZWc=
github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b h1:fO7EfkEqzLRC8Ev22jIq05fPs+JwAB7bCDy6FA+GA5k=
github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b/go.mod h1:VBC/zEphSZgCZS1rhWsR3A8EWYSbTkP/MwqWHL7266s=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand All @@ -11,6 +9,4 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
204 changes: 204 additions & 0 deletions internal/cronoapi/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package cronoapi

import (
"context"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"time"
)

// DefaultBaseURL is the production Cronometer host.
const DefaultBaseURL = "https://cronometer.com"

const (
defaultTimeout = 60 * time.Second
userAgentHdr = "crono-export-cli (clean-room; https://github.com/quantcli/crono-export-cli)"
)

// Client is an authenticated Cronometer session. Construct it with
// NewClient, then call Login. After Login the client holds the GWT
// session auth token and user ID and can call any of the Export*
// methods. Logout tears down the session, best-effort.
//
// Client is not safe for concurrent use by multiple goroutines.
type Client struct {
HTTPClient *http.Client

baseURL string
permutation string
userAgent string

userID int
authToken string
}

// NewClient returns a fresh Client. If httpClient is nil a default
// client with a 60-second timeout and a cookie jar is constructed.
// If httpClient is non-nil but has no Jar set, a cookie jar is
// installed so session cookies round-trip across requests.
func NewClient(httpClient *http.Client) *Client {
if httpClient == nil {
jar, _ := cookiejar.New(nil)
httpClient = &http.Client{
Jar: jar,
Timeout: defaultTimeout,
}
} else if httpClient.Jar == nil {
jar, _ := cookiejar.New(nil)
httpClient.Jar = jar
}
return &Client{
HTTPClient: httpClient,
baseURL: DefaultBaseURL,
permutation: DefaultGWTPermutation,
userAgent: userAgentHdr,
}
}

// SetBaseURL overrides the Cronometer host. Intended for tests.
func (c *Client) SetBaseURL(u string) { c.baseURL = strings.TrimRight(u, "/") }

// SetPermutation overrides the GWT permutation hash sent on
// /cronometer/app calls. Useful when Cronometer rotates their deploy
// hash and we need to point at the new value without a code release.
func (c *Client) SetPermutation(p string) { c.permutation = p }

// AuthToken returns the GWT session auth token from the most recent
// successful Login. Empty before Login or after Logout. Exposed for
// debugging; not normally needed by callers.
func (c *Client) AuthToken() string { return c.authToken }

// UserID returns the Cronometer user ID from the most recent
// successful Login. Zero before Login or after Logout.
func (c *Client) UserID() int { return c.userID }

// anticsrfRe extracts the hidden anti-CSRF token from the login HTML.
// WIRE_SHAPES.md §(1).
var anticsrfRe = regexp.MustCompile(`name="anticsrf"\s+value="([^"]+)"`)

// fetchAntiCSRF performs the anonymous GET /login/ that bootstraps the
// session cookie jar and returns the anti-CSRF form token embedded in
// the response HTML.
func (c *Client) fetchAntiCSRF(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/login/", nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("GET /login/: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET /login/: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read /login/ body: %w", err)
}
m := anticsrfRe.FindSubmatch(body)
if m == nil {
return "", fmt.Errorf("anti-CSRF token not found in /login/ response")
}
return string(m[1]), nil
}

// submitLogin posts the credential form to /login. Cronometer responds
// with a small JSON body and additional Set-Cookie entries on success.
// The cookie jar on c.HTTPClient picks those up automatically. We do
// not parse the JSON success body — its only documented failure mode
// is `{"error":"AntiCSRF Token Invalid"}`, which we surface explicitly.
func (c *Client) submitLogin(ctx context.Context, username, password, csrfToken string) error {
form := url.Values{}
form.Set("anticsrf", csrfToken)
form.Set("password", password)
form.Set("username", username)

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/login", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", c.userAgent)

resp, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("POST /login: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read /login body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("POST /login: HTTP %d: %s", resp.StatusCode, truncate(string(body), 200))
}
bodyStr := string(body)
if strings.Contains(bodyStr, `"error"`) {
return fmt.Errorf("login failed: %s", truncate(bodyStr, 200))
}
return nil
}

// Login performs the three-step Cronometer authentication handshake:
//
// 1. GET /login/ — bootstrap cookies + read anti-CSRF token.
// 2. POST /login — credential submission.
// 3. POST /cronometer/app (GWT-RPC authenticate) — fetch userID +
// session auth token used to mint export nonces.
//
// Steps map 1:1 to WIRE_SHAPES.md §(1), §(2), and §(3).
func (c *Client) Login(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("login: username and password required")
}
csrf, err := c.fetchAntiCSRF(ctx)
if err != nil {
return err
}
if err := c.submitLogin(ctx, username, password, csrf); err != nil {
return err
}

// Step 3: GWT-RPC authenticate. UTC offset in minutes for the host
// timezone — captured payload sent -300 (NYC DST). We send the
// current local zone's offset so the response reflects the user's
// local calendar.
_, offsetSec := time.Now().Zone()
body, err := c.gwtCall(ctx, authenticateBody(c.permutation, offsetSec/60))
if err != nil {
return fmt.Errorf("authenticate: %w", err)
}
uid, tok, err := parseAuthenticateResponse(body)
if err != nil {
return err
}
c.userID = uid
c.authToken = tok
return nil
}

// Logout calls the GWT-RPC logout method. WIRE_SHAPES.md §(14) notes
// this is best-effort: crono-export-cli already calls it via defer and
// ignores the returned error.
func (c *Client) Logout(ctx context.Context) error {
if c.authToken == "" {
return nil
}
body, err := c.gwtCall(ctx, logoutBody(c.permutation, c.authToken))
c.authToken = ""
c.userID = 0
if err != nil {
return err
}
if !strings.HasPrefix(body, "//OK") {
return fmt.Errorf("logout: unexpected response: %s", truncate(body, 80))
}
return nil
}
Loading
Loading