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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ cronometer-capture/
tools/wirecapture/captures/
tools/wirecapture/wirecapture
*.unredacted.json
.DS_Store
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,17 @@ export CRONOMETER_USERNAME="you@example.com"
export CRONOMETER_PASSWORD="your-cronometer-password"
```

The CLI logs in on every invocation; there's no token cache. Cronometer doesn't (yet) offer SSO or API tokens for individuals, so a real password is the only auth option.
Cronometer doesn't (yet) offer SSO or API tokens for individuals, so a real password is the only auth option.

After the first successful login, the session (auth token + cookies) is cached at `$XDG_CACHE_HOME/crono-export/session.json` (file mode `0600`, directory mode `0700`; on macOS this resolves to `~/Library/Caches/crono-export/session.json`). Subsequent invocations reuse the cached session and skip the login handshake — useful for LLM-agent workflows that fire several commands in quick succession (a fresh login on every call trips Cronometer's "Too Many Attempts" throttle after ~6 requests). When the cached session goes stale, the CLI transparently re-logs in and retries once.

Escape hatches:

- `CRONOMETER_NO_CACHE=1` forces a fresh login on every invocation (and skips writing the cache).
- `crono-export auth logout` deletes the cached session.
- `crono-export auth status` reports whether credentials are set and whether a session is cached.

Treat `session.json` like a password: don't sync it to a shared backup, and don't commit it.

## Usage

Expand Down
38 changes: 37 additions & 1 deletion cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"os"

"github.com/spf13/cobra"

"github.com/quantcli/crono-export-cli/internal/cronoclient"
)

var authCmd = &cobra.Command{
Expand Down Expand Up @@ -36,12 +38,46 @@ https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`,
case pass == "":
return fmt.Errorf("missing CRONOMETER_PASSWORD")
}
fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (env-var auth, no token cache)\n", user)
cacheState := "no cache"
if os.Getenv("CRONOMETER_NO_CACHE") == "" {
if p := cronoclient.SessionCachePath(); p != "" {
if _, err := os.Stat(p); err == nil {
cacheState = "session cached"
} else {
cacheState = "no session cached"
}
}
} else {
cacheState = "cache disabled (CRONOMETER_NO_CACHE set)"
}
fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (%s)\n", user, cacheState)
return nil
},
}

var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Delete the cached Cronometer session, forcing a fresh login next call",
Long: `Remove the on-disk session cache at $XDG_CACHE_HOME/crono-export/session.json.
Useful after rotating your password or when you suspect a stale session
is causing failures.

This is a local-only operation: it does NOT call Cronometer's logout
endpoint (that would invalidate the cached cookies for a session we've
already deleted). The next export call will perform a fresh login.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
p, err := cronoclient.DeleteCachedSession()
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "session cache cleared (%s)\n", p)
return nil
},
}

func init() {
authCmd.AddCommand(authStatusCmd)
authCmd.AddCommand(authLogoutCmd)
rootCmd.AddCommand(authCmd)
}
9 changes: 7 additions & 2 deletions cmd/biometrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ import (
)

var biometricsCmd = &cobra.Command{
Use: "biometrics",
Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)",
Use: "biometrics",
Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)",
Args: cobra.NoArgs,
PreRunE: ValidateExportFlags,
RunE: func(cmd *cobra.Command, _ []string) error {
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
if err != nil {
return err
}
if rng.IsEmpty() {
return emit(cmd, kindBiometrics, emptyValueFor(kindBiometrics))
}
ctx := cmd.Context()
c, err := cronoclient.NewLoggedIn(ctx)
if err != nil {
Expand Down
9 changes: 7 additions & 2 deletions cmd/exercises.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ import (
)

var exercisesCmd = &cobra.Command{
Use: "exercises",
Short: "Export logged exercises (cardio, strength, custom activities)",
Use: "exercises",
Short: "Export logged exercises (cardio, strength, custom activities)",
Args: cobra.NoArgs,
PreRunE: ValidateExportFlags,
RunE: func(cmd *cobra.Command, _ []string) error {
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
if err != nil {
return err
}
if rng.IsEmpty() {
return emit(cmd, kindExercises, emptyValueFor(kindExercises))
}
ctx := cmd.Context()
c, err := cronoclient.NewLoggedIn(ctx)
if err != nil {
Expand Down
121 changes: 81 additions & 40 deletions cmd/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ func AddFormatFlags(cmd *cobra.Command) {
"Output format: markdown (default, fitdown-style) or json")
}

// ValidateExportFlags is a PreRunE that fails fast on bad --format or date
// flags before any network call is made. Without this, a typo in --format
// would burn a Cronometer login attempt against the rate limit.
func ValidateExportFlags(cmd *cobra.Command, _ []string) error {
if _, err := chosenFormat(cmd); err != nil {
return err
}
return nil
}

func chosenFormat(cmd *cobra.Command) (string, error) {
f, _ := cmd.Flags().GetString("format")
switch f {
Expand Down Expand Up @@ -70,20 +80,40 @@ func renderMarkdown(w io.Writer, kind recordKind, v any) error {
recs, _ := v.(cronoapi.ExerciseRecords)
return renderExercises(w, recs)
case kindNutrition:
rows, _ := v.([]map[string]string)
rows, _ := v.([]map[string]any)
return renderNutrition(w, rows)
case kindNotes:
rows, _ := v.([]map[string]string)
rows, _ := v.([]map[string]any)
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
// noteEmpty writes a friendly "no records" note to stderr so humans see it,
// while keeping stdout clean per the contract's "data only on stdout" rule.
// w is ignored — kept for the renderer signatures.
func noteEmpty(_ io.Writer) error {
fmt.Fprintln(os.Stderr, "(no records in window)")
return nil
}

// emptyValueFor returns the typed empty value for a record kind. Used
// when the caller knows there are no records (e.g. inverted date range)
// and wants to emit them without making a network call.
func emptyValueFor(kind recordKind) any {
switch kind {
case kindServings:
return cronoapi.ServingRecords{}
case kindBiometrics:
return cronoapi.BiometricRecords{}
case kindExercises:
return cronoapi.ExerciseRecords{}
case kindNutrition, kindNotes:
return []map[string]any{}
}
return nil
}

// fmtFloat trims trailing zeros so 1.95 → "1.95" and 100.000 → "100".
Expand All @@ -103,7 +133,7 @@ func strippedSuffix(field string) (name, unit string) {
{"Kcal", "kcal"},
{"Mg", "mg"},
{"Ug", "µg"},
{"UI", "IU"},
{"IU", "IU"},
{"G", "g"},
} {
if strings.HasSuffix(field, suf.go_) && len(field) > len(suf.go_) {
Expand All @@ -117,7 +147,7 @@ func strippedSuffix(field string) (name, unit string) {

func renderServings(w io.Writer, recs cronoapi.ServingRecords) error {
if len(recs) == 0 {
return emptyMsg(w)
return noteEmpty(w)
}
// Group by local calendar date.
byDate := map[string][]cronoapi.ServingRecord{}
Expand Down Expand Up @@ -194,7 +224,7 @@ func strDefault(s, fallback string) string {

func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {
if len(recs) == 0 {
return emptyMsg(w)
return noteEmpty(w)
}
byDate := map[string][]cronoapi.BiometricRecord{}
for _, r := range recs {
Expand Down Expand Up @@ -226,7 +256,7 @@ func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {

func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {
if len(recs) == 0 {
return emptyMsg(w)
return noteEmpty(w)
}
byDate := map[string][]cronoapi.ExerciseRecord{}
for _, r := range recs {
Expand Down Expand Up @@ -263,19 +293,19 @@ func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {

// ---- nutrition (daily totals, string-keyed CSV) ----------------------

func renderNutrition(w io.Writer, rows []map[string]string) error {
func renderNutrition(w io.Writer, rows []map[string]any) error {
if len(rows) == 0 {
return emptyMsg(w)
return noteEmpty(w)
}
// Sort by Date asc.
sort.SliceStable(rows, func(i, j int) bool {
return rows[i]["Date"] < rows[j]["Date"]
return cellString(rows[i]["Date"]) < cellString(rows[j]["Date"])
})
for di, row := range rows {
if di > 0 {
fmt.Fprintln(w)
}
date := row["Date"]
date := cellString(row["Date"])
if date == "" {
date = "(unknown date)"
}
Expand All @@ -294,42 +324,54 @@ func renderNutrition(w io.Writer, rows []map[string]string) error {
if isZeroish(v) {
continue
}
fmt.Fprintf(w, "- %s: %s\n", k, v)
fmt.Fprintf(w, "- %s: %s\n", k, cellString(v))
}
}
fmt.Fprintln(w)
fmt.Fprintln(w, "_zero-valued nutrients omitted; use --format 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
}
// cellString renders a coerced CSV cell back to a display string. Floats
// drop trailing zeros so "1.5" stays "1.5" and "100" stays "100".
func cellString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case float64:
return fmtFloat(x)
case bool:
if x {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", x)
}
}

// isZeroish reports whether a coerced CSV value should be hidden from the
// markdown output: nil, empty string, or numeric zero. "false" / "true" /
// arbitrary text is rendered.
func isZeroish(v any) bool {
switch x := v.(type) {
case nil:
return true
case string:
return x == ""
case float64:
return x == 0
}
return false
}

// ---- notes ------------------------------------------------------------

func renderNotes(w io.Writer, rows []map[string]string) error {
func renderNotes(w io.Writer, rows []map[string]any) error {
if len(rows) == 0 {
return emptyMsg(w)
return noteEmpty(w)
}
dateKey := pickKey(rows[0], "Day", "Date")
noteKey := pickKey(rows[0], "Note", "Notes", "Comment")
Expand All @@ -339,36 +381,35 @@ func renderNotes(w io.Writer, rows []map[string]string) error {
if di > 0 {
fmt.Fprintln(w)
}
date := row[dateKey]
date := cellString(row[dateKey])
if date == "" {
date = "(unknown date)"
}
header := "## " + date
if t := row[timeKey]; t != "" {
if t := cellString(row[timeKey]); t != "" {
header += " " + t
}
fmt.Fprintln(w, header)
if note := strings.TrimSpace(row[noteKey]); note != "" {
if note := strings.TrimSpace(cellString(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 == "" {
if k == dateKey || k == timeKey || isZeroish(v) {
continue
}
fmt.Fprintf(w, "- %s: %s\n", k, v)
fmt.Fprintf(w, "- %s: %s\n", k, cellString(v))
}
}
}
return nil
}

func pickKey(row map[string]string, candidates ...string) string {
func pickKey(row map[string]any, candidates ...string) string {
for _, c := range candidates {
if _, ok := row[c]; ok {
return c
}
}
return ""
}

25 changes: 25 additions & 0 deletions cmd/format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package cmd

import "testing"

func TestStrippedSuffix(t *testing.T) {
cases := []struct {
field, wantName, wantUnit string
}{
{"EnergyKcal", "Energy", "kcal"},
{"CarbsG", "Carbs", "g"},
{"CalciumMg", "Calcium", "mg"},
{"VitaminAUg", "VitaminA", "µg"},
{"VitaminDIU", "VitaminD", "IU"},
{"PolyunsaturatedG", "Polyunsaturated", "g"},
{"Group", "Group", ""},
{"G", "G", ""},
}
for _, c := range cases {
gotName, gotUnit := strippedSuffix(c.field)
if gotName != c.wantName || gotUnit != c.wantUnit {
t.Errorf("strippedSuffix(%q) = (%q, %q), want (%q, %q)",
c.field, gotName, gotUnit, c.wantName, c.wantUnit)
}
}
}
Loading
Loading