Skip to content

Latest commit

 

History

16 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

runs

Mirror your Garmin Connect and Strava running data to local JSON. Built for runners who want their own data on disk for analysis in notebooks, jq queries, or LLM prompts.

The tool only fetches. It doesn't analyze your training, predict races, or send anything to the cloud. The output is plain JSON files (plus raw .fit files) you can read with any tool you like.

It's a single self-contained Go binary. There's no .env file. It prompts for credentials when it needs them and caches the resulting tokens so later syncs run unattended.

What it does

  • Downloads every running activity from your Garmin Connect account (summary, details, splits, raw .fit files) plus daily wellness data (HRV, sleep, body battery, training readiness, training status).
  • Downloads every Run activity from your Strava account (detail and per-activity streams).
  • Writes a unified runs.json that merges both sources into one list, each row tagged with its source, so an analysis can load your whole history at once.
  • Re-running fetches only what's missing. The on-disk layout is stable so downstream analysis can build on it.

Install

go install github.com/joelsouza/runs/cmd/runs@latest

Or build from a clone:

go build -o runs ./cmd/runs

Requires Go 1.25 or newer. Tested on macOS and Linux.

First-time setup

There's no config file to edit. The first time a command needs credentials, it asks.

Garmin. Run runs garmin login (or just runs garmin sync, which logs in when no token is cached). It prompts for your email, password, and an MFA code if your account uses one, then caches OAuth tokens at ~/.garminconnect/ for about a year.

A caveat worth knowing: Garmin actively blocks scripted logins, so the from-scratch login can fail with a rate-limit or a non-JSON challenge. If that happens, the reliable path is the token cache. As long as a valid token sits in ~/.garminconnect/garmin_tokens.json, runs refreshes it on its own and never needs the password again. The same file is produced by the Python garminconnect library, so an existing cache from that tool works directly.

Strava. Run runs strava login. It asks for the CLIENT_ID and CLIENT_SECRET of an API app (create one at https://www.strava.com/settings/api), then walks you through a paste-the-code OAuth flow: the browser opens the authorization page, redirects to a localhost URL that fails to load, and you copy the code= value from the address bar back to the prompt. It saves tokens to ~/.config/runs/strava.json and refreshes them automatically.

Sync

# Pull everything, then rebuild the unified index
runs sync

# Pull just one source
runs garmin sync
runs strava sync

# Skip Garmin wellness (much faster if you only want activities)
runs garmin sync --skip-wellness

# Backfill Garmin wellness from your earliest run (slow, ~1 API call/day)
runs garmin sync --full-wellness

# Limit to recent activities
runs sync --since 2025-01-01

# Refetch everything from scratch (rarely needed)
runs sync --force

Re-running with no flags is fast. Cached files are validated (JSON parses, FIT header looks valid) and skipped.

Where the data lives

By default both sources write to your current working directory:

./garmin/
├── index.json                         # one row per Garmin run
├── activities/
│   └── <id>/
│       ├── summary.json               # headline metrics
│       ├── details.json               # running dynamics, HR zones
│       ├── splits.json                # per-km splits
│       └── activity.fit               # raw FIT file
├── wellness/
│   └── YYYY-MM-DD.json                # daily HRV/sleep/etc
└── .last_wellness_sync.json           # anchor for incremental backfill

./strava/
├── index.json                         # one row per Strava run
└── activities/
    └── <id>/
        ├── detail.json                # full activity object
        └── streams.json               # time-series GPS/HR/cadence

./runs.json                            # both sources merged, sorted by date

Override the location with --data-dir <path>, or set RUNS_DATA_DIR=<path> in your environment.

Consume the data

The unified index is the fastest way in:

import json
runs = json.load(open("runs.json"))["runs"]
garmin = [r for r in runs if r["source"] == "garmin"]
print(f"{len(runs)} runs total, {len(garmin)} from Garmin")

Per-source index files (garmin/index.json, strava/index.json) carry the richer source-specific fields when you need them.

Health check

runs doctor

Prints [OK] / [WARN] / [FAIL] lines for the Garmin token cache, Strava config, token freshness, data-dir writability, and index row counts. Useful when something doesn't look right and you can't tell which layer is broken.

What it isn't

  • A training-analysis tool. Use the JSON output with any analyzer you like.
  • A multi-user service. Runs locally with your own credentials.
  • A real-time syncer. Run on demand.

License

MIT. See LICENSE.

About

Export your Garmin and Strava runs to local JSON

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages