
Garmin Connect
- 258 installs
- 11 repo stars
- Updated May 20, 2026
- eddmann/garmin-connect-cli
For development and infrastructure management.
About
garmin-connect is an AI coding tool that enhances development workflows. Builders use it for infrastructure, integration, and platform development within the catalog ecosystem.
- garmin-connect
- Development
Garmin Connect by the numbers
- 258 all-time installs (skills.sh)
- Ranked #1,477 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/eddmann/garmin-connect-cli --skill garmin-connectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 258 |
|---|---|
| repo stars | ★ 11 |
| Last updated | May 20, 2026 |
| Repository | eddmann/garmin-connect-cli ↗ |
What it does
For development and infrastructure management.
Files
Garmin Connect CLI Skill
Query and manage Garmin Connect data via the garmin-connect CLI.
Prerequisites
- Install CLI:
curl -fsSL https://raw.githubusercontent.com/eddmann/garmin-connect-cli/main/install.sh | sh - Authenticate:
garmin-connect auth login(email/password, supports MFA)
Quick Context
Get aggregated data in one call:
garmin-connect context # Full context: profile, stats, health, activities
garmin-connect context --activities 10 # More recent activities
garmin-connect context --focus stats,health # Specific sections onlyCommands
Run garmin-connect --help or garmin-connect <command> --help to discover all options.
Activities
garmin-connect activities list [--after DATE] [--before DATE] [--limit N] [--type TYPE]
garmin-connect activities get <ID> [--details]
garmin-connect activities splits <ID>
garmin-connect activities download <ID> [--format TCX|GPX|FIT] [-o FILE]
garmin-connect activities upload <FILE>
garmin-connect activities delete <ID> [--force]Athlete
garmin-connect athlete # Profile
garmin-connect athlete stats # Daily statistics
garmin-connect athlete summary # Comprehensive stats + body metricsHealth
garmin-connect health sleep [--date DATE]
garmin-connect health heart-rate [--date DATE]
garmin-connect health steps [--date DATE]
garmin-connect health stress [--date DATE]
garmin-connect health body-battery [--date DATE]
garmin-connect health rhr [--date DATE]Training
garmin-connect training status [--date DATE] # Productive, Peaking, etc.
garmin-connect training readiness [--date DATE] # Readiness score (0-100)
garmin-connect training vo2max [--date DATE]
garmin-connect training hrv [--date DATE]
garmin-connect training fitness-ageWeight
garmin-connect weight list [--start DATE] [--end DATE]
garmin-connect weight get [--date DATE]
garmin-connect weight log <WEIGHT_KG> [--date DATE]Data Units
| Field | Unit |
|---|---|
| distance | meters |
| duration, movingTime | seconds |
| averageSpeed, maxSpeed | m/s |
| elevation | meters |
| dates | ISO8601 |
Common Patterns
# Recent activities
garmin-connect activities list --limit 10
# This month's activities
garmin-connect activities list --after 2025-12-01
# Filter with jq
garmin-connect activities list | jq '[.[] | select(.activityType.typeKey=="running")]'
# Total distance
garmin-connect activities list | jq '[.[].distance] | add'Auth Status
garmin-connect auth status # Check if authenticated
garmin-connect auth login # Login with email/password (supports MFA)
garmin-connect auth logout # Clear stored tokensActivity Types
running, cycling, swimming, walking, hiking, trail_running, open_water_swimming, indoor_cycling, virtual_cycling, strength_training, cardio, yoga, pilates, elliptical, indoor_rowing, other
Exit Codes
- 0 = Success
- 1 = General error
- 2 = Auth error (run
garmin-connect auth login)
name: Deploy to GitHub Pages
on:
push:
branches:
- main
paths:
- 'site/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Fetch latest release version
id: version
env:
GH_TOKEN: ${{ github.token }}
run: |
RELEASE_TAG=$(gh release view --json tagName --jq '.tagName' 2>/dev/null || echo "")
if [ -n "$RELEASE_TAG" ]; then
VERSION="${RELEASE_TAG#v}"
echo "Found release version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
else
echo "No releases found, keeping default version"
echo "version=" >> $GITHUB_OUTPUT
fi
- name: Inject version into site
if: steps.version.outputs.version != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Updating softwareVersion to $VERSION in site/index.html"
sed -i 's/"softwareVersion": "[^"]*"/"softwareVersion": "'"$VERSION"'"/' site/index.html
grep -o '"softwareVersion": "[^"]*"' site/index.html
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: './site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
name: Release
on:
workflow_dispatch:
jobs:
build:
strategy:
matrix:
include:
- os: macos-latest
artifact: garmin-connect-macos-arm64
- os: macos-15-intel
artifact: garmin-connect-macos-x64
- os: ubuntu-latest
artifact: garmin-connect-linux-x64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Extract version from changelog
id: version
run: |
VERSION=$(sed -n 's/^## \[\([^]]*\)\].*/\1/p' CHANGELOG.md | head -1)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set up uv
uses: astral-sh/setup-uv@v8.1.0
- name: Set version
run: make set-version VERSION=${{ steps.version.outputs.version }}
- name: Install dependencies
run: make deps
- name: Build binary
run: make binary
- name: Rename binary
run: mv dist/garmin-connect dist/${{ matrix.artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: dist/${{ matrix.artifact }}
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Extract changelog
id: changelog
run: |
VERSION=$(sed -n 's/^## \[\([^]]*\)\].*/\1/p' CHANGELOG.md | head -1)
echo "version=$VERSION" >> $GITHUB_OUTPUT
CONTENT=$(awk '
/^## \[/ {
if (found) exit
found=1
next
}
found && /^\[[^]]+\]:/ { exit }
found { print }
' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Set up uv
uses: astral-sh/setup-uv@v8.1.0
with:
enable-cache: true
- name: Set version
run: make set-version VERSION=${{ steps.changelog.outputs.version }}
- name: Install dependencies
run: make deps
- name: Run release checks
run: make can-release
- name: Build package
run: make package/check
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
cp "$dir$name" "release/$name"
chmod +x "release/$name"
done
ls -la release/
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ steps.changelog.outputs.version }}
name: garmin-connect-cli v${{ steps.changelog.outputs.version }}
body: ${{ steps.changelog.outputs.content }}
files: |
release/*
dist/*.whl
dist/*.tar.gz
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update Homebrew tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
SHA_ARM=$(shasum -a 256 release/garmin-connect-macos-arm64 | cut -d' ' -f1)
SHA_X64=$(shasum -a 256 release/garmin-connect-macos-x64 | cut -d' ' -f1)
git clone https://x-access-token:${TAP_TOKEN}@github.com/eddmann/homebrew-tap.git
cd homebrew-tap
# Update version
sed -i "s/version \".*\"/version \"${{ steps.changelog.outputs.version }}\"/" Formula/garmin-connect-cli.rb
# Update ARM SHA256 (within on_arm block)
sed -i "/on_arm/,/end/{s/sha256 \"[a-f0-9]*\"/sha256 \"${SHA_ARM}\"/;}" Formula/garmin-connect-cli.rb
# Update Intel SHA256 (within on_intel block)
sed -i "/on_intel/,/end/{s/sha256 \"[a-f0-9]*\"/sha256 \"${SHA_X64}\"/;}" Formula/garmin-connect-cli.rb
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/garmin-connect-cli.rb
git commit -m "chore(garmin-connect-cli): update to ${{ steps.changelog.outputs.version }}"
git push
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
run: make install
- name: Lint
run: make lint
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: make install
- name: Test
run: make test
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Test artifacts
.coverage
.coverage.*
htmlcov/
.pytest_cache/
# Ruff
.ruff_cache/
# Environment
.env
.env.local
# IDE
.idea/
.vscode/
*.swp
.DS_Store
3.12
Prefer make targets over direct commands. Run make help to see available targets.
Run make can-release before wrapping up substantial changes.
Detroit-style tests: assert on observable behaviour and mock only at the boundary.
Use garmin-connect as the public executable name in docs, examples, tests, and release assets. Keep garmin-connect-cli as the package name and uvx alias only.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[1.0.1] - 2026-05-20
Changed
- Add PyPI package publishing so the CLI can be run with
uvx garmin-connect-cli
[1.0.0] - 2026-05-20
Changed
- Update Garmin authentication to use the python-garminconnect 0.3 token flow
- Require Python 3.12+ to match the latest python-garminconnect release
[0.1.1] - 2026-01-20
Changed
- Release workflow now extracts version and release notes automatically from CHANGELOG.md
[0.1.0] - 2025-12-23
Added
- Initial Garmin Connect CLI implementation
- Access Garmin Connect data from your terminal
- Machine-readable output (JSON, JSONL, CSV, TSV)
- Human-friendly table output
- Session-based authentication with token persistence
- Activities, health metrics, training status, weight tracking
- LLM-optimized context aggregation
Fixed
- Use macos-15-intel instead of deprecated macos-13 in release workflow
[1.0.1]: https://github.com/eddmann/garmin-connect-cli/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/eddmann/garmin-connect-cli/compare/v0.1.1...v1.0.0 [0.1.1]: https://github.com/eddmann/garmin-connect-cli/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/eddmann/garmin-connect-cli/releases/tag/v0.1.0
AGENTS.md
#!/bin/sh
set -e
REPO="eddmann/garmin-connect-cli"
SKILL_URL="https://raw.githubusercontent.com/${REPO}/main/SKILL.md"
echo "Installing garmin-connect agent skill..."
echo ""
# Install for Claude Code
CLAUDE_DIR="${HOME}/.claude/skills/garmin-connect"
mkdir -p "$CLAUDE_DIR"
curl -fsSL "$SKILL_URL" -o "${CLAUDE_DIR}/SKILL.md"
echo " Installed: ${CLAUDE_DIR}/SKILL.md"
# Install for Cursor
CURSOR_DIR="${HOME}/.cursor/skills/garmin-connect"
mkdir -p "$CURSOR_DIR"
curl -fsSL "$SKILL_URL" -o "${CURSOR_DIR}/SKILL.md"
echo " Installed: ${CURSOR_DIR}/SKILL.md"
echo ""
echo "Agent skill installed for:"
echo " - Claude Code"
echo " - Cursor"
echo ""
echo "(Re-run this script to update)"
echo ""
echo "The skill will be auto-detected when you ask about Garmin/fitness data."
echo ""
echo "Prerequisites:"
echo " - garmin-connect must be installed (curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sh)"
echo " - Run 'garmin-connect auth login' to authenticate"
#!/bin/sh
set -e
REPO="eddmann/garmin-connect-cli"
INSTALL_DIR="${HOME}/.local/bin"
# Detect OS
OS=$(uname -s)
case "$OS" in
Darwin) OS_NAME="macos" ;;
Linux) OS_NAME="linux" ;;
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_NAME="x64" ;;
aarch64) ARCH_NAME="arm64" ;;
arm64) ARCH_NAME="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
# Linux only has x64 builds
if [ "$OS_NAME" = "linux" ] && [ "$ARCH_NAME" = "arm64" ]; then
echo "Linux arm64 builds not available, falling back to x64"
ARCH_NAME="x64"
fi
BINARY_NAME="garmin-connect-${OS_NAME}-${ARCH_NAME}"
echo "Installing garmin-connect..."
echo " OS: $OS_NAME"
echo " Arch: $ARCH_NAME"
# Get latest release URL
LATEST_URL=$(curl -sI "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/.*tag\///' | tr -d '\r\n')
if [ -z "$LATEST_URL" ]; then
echo "Error: Could not determine latest release"
exit 1
fi
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${LATEST_URL}/${BINARY_NAME}"
echo " Version: $LATEST_URL"
echo " URL: $DOWNLOAD_URL"
# Create install directory
mkdir -p "$INSTALL_DIR"
# Download binary
echo "Downloading..."
curl -fsSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/garmin-connect"
chmod +x "${INSTALL_DIR}/garmin-connect"
echo ""
echo "Installed garmin-connect to ${INSTALL_DIR}/garmin-connect"
echo "(Re-run this script to update)"
# Check if in PATH
if echo "$PATH" | grep -q "$INSTALL_DIR"; then
echo ""
echo "Run 'garmin-connect --help' to get started"
else
echo ""
echo "Add ${INSTALL_DIR} to your PATH:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
echo ""
echo "Then run 'garmin-connect --help' to get started"
fi
echo ""
echo "Next step: garmin-connect auth login"
MIT License
Copyright (c) 2025 Edd Mann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
.PHONY: *
.DEFAULT_GOAL := help
SHELL := /bin/bash
VERSION := $(shell grep '^version' pyproject.toml | cut -d '"' -f 2)
##@ Setup
deps: ## Install dependencies
@uv sync
deps/prod: ## Install production dependencies only
@uv sync --no-dev
install: deps
update: ## Update all dependencies to latest versions
@uv lock --upgrade
@uv sync
lock: ## Regenerate lock file from scratch
@rm -f uv.lock
@uv lock
clean: ## Clean up cache files and build artifacts
@rm -rf .pytest_cache/ .ruff_cache/ .mypy_cache/ .pyright/
@find . -type d -name __pycache__ -exec rm -rf {} +
@find . -type f -name "*.pyc" -delete
@rm -rf dist/ build/ *.egg-info/
##@ Testing/Linting
can-release: lint test ## Run all the same checks as CI to ensure code can be released
test: ## Run the test suite
@uv run python -m pytest
test/%: ## Run tests with a filter (e.g., make test/activity)
@uv run python -m pytest -k $*
test/verbose: ## Run tests with verbose output
@uv run python -m pytest -v
test/coverage: ## Run tests with coverage report
@uv run python -m pytest --cov=src/garmin_connect_cli --cov-report=term-missing
lint: lint/ruff lint/pyright ## Run all linting tools
lint/ruff: ## Run ruff linter
@uv run ruff check
@uv run ruff format --check
lint/pyright: ## Run pyright type checker
@uv run python -m pyright
fmt: format
format: ## Fix style violations and format code
@uv run ruff check --fix
@uv run ruff format
##@ Packaging
set-version: ## Set version (VERSION=x.x.x)
@sed -i.bak 's/^version = "[^"]*"/version = "$(VERSION)"/' pyproject.toml
@sed -i.bak 's/__version__ = "[^"]*"/__version__ = "$(VERSION)"/' src/garmin_connect_cli/__init__.py
@rm -f pyproject.toml.bak src/garmin_connect_cli/__init__.py.bak
build: clean ## Build source and wheel distributions
@uv build
package/check: build ## Validate built distributions
@uvx twine check dist/*
binary: clean ## Build standalone binary
@uv run pyinstaller \
--onefile \
--name garmin-connect \
--distpath dist \
--specpath build \
--workpath build/work \
src/garmin_connect_cli/cli.py
##@ Development
auth: ## Run the Garmin authentication setup
@uv run garmin-connect auth login
run: ## Run a garmin-connect command (CMD="activities list")
@uv run garmin-connect $(CMD)
shell: ## Open a Python shell with the project context
@uv run python
##@ Info
version: ## Show current version
@echo $(VERSION)
deps/list: ## Show installed dependencies
@uv pip list
info: ## Show project information
@echo "Project: garmin-connect-cli"
@echo "Version: $(VERSION)"
@echo "Python: $$(python --version)"
help: ## Show this help message
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_\-\/]+:.*?##/ { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
t: test
[project]
name = "garmin-connect-cli"
version = "0.0.0"
description = "Garmin Connect from your terminal. Pipe it, script it, automate it."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Edd Mann" }]
keywords = ["garmin", "cli", "fitness", "health", "running", "cycling"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Utilities",
]
dependencies = [
"garminconnect>=0.3.3",
"typer>=0.15.0",
]
[project.scripts]
garmin-connect = "garmin_connect_cli.cli:app"
garmin-connect-cli = "garmin_connect_cli.cli:app"
[project.urls]
Repository = "https://github.com/eddmann/garmin-connect-cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.sdist]
include = ["/LICENSE", "/README.md", "/pyproject.toml", "/src"]
[tool.hatch.build.targets.wheel]
packages = ["src/garmin_connect_cli"]
[tool.uv]
dev-dependencies = [
"pytest>=8.0",
"ruff>=0.8",
"pyright>=1.1.390",
"pyinstaller",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "basic"
reportMissingTypeStubs = false
Garmin Connect CLI
!Garmin Connect CLI
Garmin Connect from your terminal. Pipe it, script it, automate it.
 
Exploring CLI tools as skills for AI agents. Background below.
Features
- All your Garmin data — activities, stats, sleep, heart rate, stress, body battery
- Script and automate — composable with jq, pipes, xargs, and standard Unix tools
- AI agent ready — install the skill for Claude, Cursor, and other assistants
- Flexible output — JSON for scripts, CSV for spreadsheets, tables for humans
Installation
Quick Install (Recommended)
curl -fsSL https://raw.githubusercontent.com/eddmann/garmin-connect-cli/main/install.sh | shDownloads the pre-built binary for your platform (macOS/Linux) to ~/.local/bin.
Homebrew
brew install eddmann/tap/garmin-connect-cliUsing uv
Requires Python 3.12+ and uv.
uvx garmin-connect-cli --help
uv tool install garmin-connect-cliThe PyPI package is garmin-connect-cli; the CLI command remains garmin-connect.
From Source
git clone https://github.com/eddmann/garmin-connect-cli
cd garmin-connect-cli
make deps
uv run garmin-connect --helpQuick Start
# Authenticate with Garmin Connect
garmin-connect auth login
# List recent activities
garmin-connect activities list --limit 10
# Get today's stats
garmin-connect athlete stats
# Get sleep data
garmin-connect health sleep
# Get aggregated context for LLMs
garmin-connect contextCommand Reference
Global Options
| Flag | Short | Description |
|---|---|---|
--format | -f | Output format: json (default), jsonl, csv, tsv, human |
--fields | Comma-separated list of fields to include | |
--no-header | Omit header row in CSV/TSV output | |
--verbose | -v | Verbose output to stderr |
--quiet | -q | Suppress non-essential output |
--config | -c | Path to config file |
--profile | -p | Named profile to use |
--version | -V | Show version and exit |
Authentication
Tokens are stored in ~/.config/garmin-connect-cli/tokens/ and remain valid for approximately one year. MFA is supported.
garmin-connect auth login # Interactive login
garmin-connect auth login --email EMAIL # With credentials
garmin-connect auth status # Check status
garmin-connect auth logout # Clear tokens
garmin-connect auth login --profile work # Named profileCommands
Use garmin-connect <command> --help for full details.
| Command | Description |
|---|---|
activities list | List activities (supports --limit, --after, --before, --type) |
activities get <id> | Get activity details (--details for extended info) |
activities splits <id> | Get activity splits/laps |
activities download <id> | Download as GPX/TCX/FIT (--format, -o) |
activities upload <file> | Upload activity file |
activities delete <id> | Delete activity (--force to skip confirmation) |
athlete | Get user profile |
athlete stats | Daily statistics (--date) |
athlete summary | Comprehensive summary with body metrics |
health sleep | Sleep data (--date) |
health heart-rate | Heart rate data |
health rhr | Resting heart rate |
health steps | Step count |
health stress | Stress levels |
health body-battery | Body battery |
training status | Training status (Productive, Peaking, etc.) |
training readiness | Training readiness score (0-100) |
training vo2max | VO2 max estimates |
training hrv | Heart rate variability |
training fitness-age | Fitness age |
weight list | Weight entries (--start, --end) |
weight get | Weight for date (--date) |
weight log <kg> | Log weight measurement |
context | Aggregated data for LLMs (--focus, --activities, --no-health) |
Configuration
CLI preferences are stored in ~/.config/garmin-connect-cli/config.toml:
[defaults]
format = "json"
limit = 30
[profiles.work]
email = "work@example.com"Authentication tokens are managed by python-garminconnect and stored in ~/.config/garmin-connect-cli/tokens/.
Environment Variables
| Variable | Description |
|---|---|
GARMIN_EMAIL | Garmin Connect email |
GARMIN_PASSWORD | Garmin Connect password |
GARMIN_FORMAT | Default output format |
GARMIN_PROFILE | Default profile name |
GARMIN_CONFIG | Path to config file |
Composability
# Filter runs over 10km (distance in meters)
garmin-connect activities list --type running | jq '.[] | select(.distance > 10000)'
# Total running distance in km
garmin-connect activities list --type running | jq '[.[].distance] | add / 1000'
# Get recent activities with key metrics
garmin-connect activities list --limit 5 | jq '.[] | {name: .activityName, km: (.distance/1000), mins: (.duration/60)}'AI Agent Integration
This CLI is available as an Agent Skill — it works with Claude Code, Cursor, and other compatible AI agents. See `SKILL.md` for the skill definition.
Install Agent Skill
curl -fsSL https://raw.githubusercontent.com/eddmann/garmin-connect-cli/main/install-skill.sh | shInstalls the skill to ~/.claude/skills/garmin-connect/ and ~/.cursor/skills/garmin-connect/. Agents will auto-detect when you ask about Garmin/fitness data.
Development
git clone https://github.com/eddmann/garmin-connect-cli
cd garmin-connect-cli
make deps # Install dependencies
make test # Run tests
make run CMD="activities list --limit 5" # Run commandBackground
I recently built garmin-connect-mcp, an MCP server for Garmin Connect. This got me thinking about alternative approaches to giving AI agents capabilities.
There's been a lot of discussion around the heavyweight nature of MCP. An alternative approach is to give agents discoverable skills via well-documented CLI tooling. Give an LLM a terminal and let it use composable CLI tools to build up functionality and solve problems — the Unix philosophy applied to AI agents.
This project is an exploration of Claude Code Skills and the emerging Agent Skills standard for AI-tool interoperability. The goal was to build a CLI that works seamlessly as both:
1. A traditional Unix tool — composable, pipe-friendly, machine-readable 2. An AI agent skill — structured output, comprehensive documentation, predictable behavior
Going forward, another approach worth exploring is going one step further than CLI and providing a code library that agents can import and use directly.
License
MIT
Credits
Built on top of python-garminconnect by cyberjunky.
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Garmin Connect CLI - Your Fitness Data From The Terminal | Command Line Tool</title>
<meta name="title" content="Garmin Connect CLI - Your Fitness Data From The Terminal | Command Line Tool">
<meta name="description" content="Access your Garmin Connect fitness data from the terminal. Activities, stats, sleep, heart rate, and more. Script it, automate it, pipe it.">
<meta name="keywords" content="Garmin CLI, Garmin Connect, fitness data, terminal, command line, activities, heart rate, sleep tracking, fitness automation, Garmin API">
<meta name="robots" content="index, follow">
<meta name="author" content="Edd Mann">
<meta name="theme-color" content="#06b6d4">
<meta name="color-scheme" content="light dark">
<!-- Canonical URL -->
<link rel="canonical" href="https://eddmann.com/garmin-connect-cli/">
<!-- Favicons -->
<link rel="icon" type="image/png" href="logo.png">
<link rel="apple-touch-icon" href="logo.png">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://eddmann.com/garmin-connect-cli/">
<meta property="og:title" content="Garmin Connect CLI - Your Fitness Data From The Terminal">
<meta property="og:description" content="Access your Garmin Connect fitness data from the terminal. Activities, stats, sleep, heart rate, and more. Script it, automate it, pipe it.">
<meta property="og:image" content="https://eddmann.com/garmin-connect-cli/preview.png">
<meta property="og:site_name" content="Garmin Connect CLI">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://eddmann.com/garmin-connect-cli/">
<meta property="twitter:title" content="Garmin Connect CLI - Your Fitness Data From The Terminal">
<meta property="twitter:description" content="Access your Garmin Connect fitness data from the terminal. Activities, stats, sleep, heart rate, and more. Script it, automate it, pipe it.">
<meta property="twitter:image" content="https://eddmann.com/garmin-connect-cli/preview.png">
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Garmin Connect CLI",
"operatingSystem": "macOS, Linux",
"applicationCategory": "DeveloperApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"description": "Access your Garmin Connect fitness data from the terminal. Activities, stats, sleep, heart rate, and more. Script it, automate it, pipe it.",
"downloadUrl": "https://github.com/eddmann/garmin-connect-cli/releases/latest",
"softwareVersion": "1.0.0",
"author": {
"@type": "Person",
"name": "Edd Mann"
},
"image": "https://eddmann.com/garmin-connect-cli/logo.png"
}
</script>
<!-- Theme flash prevention -->
<script>
if (localStorage.getItem('theme-preference') === 'dark' ||
(!localStorage.getItem('theme-preference') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
'sans': ['DM Sans', 'system-ui', 'sans-serif'],
'mono': ['JetBrains Mono', 'monospace'],
},
colors: {
'garmin': {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#06b6d4',
600: '#0891b2',
700: '#0e7490',
800: '#155e75',
900: '#164e63',
},
},
},
},
}
</script>
<style>
* { font-family: 'DM Sans', system-ui, sans-serif; }
html, body {
background: linear-gradient(180deg, #f8fafc 0%, #ecfeff 50%, #f1f5f9 100%);
min-height: 100vh;
}
html.dark, html.dark body {
background: linear-gradient(180deg, #0f172a 0%, #0c1929 50%, #0f172a 100%);
}
.gradient-text {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glass-card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
html.dark .glass-card {
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.glass-card:hover {
transform: translateY(-8px);
box-shadow: 0 20px 40px rgba(6, 182, 212, 0.12), 0 8px 16px rgba(0, 0, 0, 0.04);
border-color: rgba(6, 182, 212, 0.3);
}
.btn-primary {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
border-radius: 16px;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4);
}
.terminal {
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
font-family: 'JetBrains Mono', monospace;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(6, 182, 212, 0.1);
}
.terminal * {
font-family: 'JetBrains Mono', monospace !important;
}
.terminal-content {
min-height: 280px;
}
.terminal-line {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.terminal-line.visible {
opacity: 1;
transform: translateY(0);
}
.cursor {
display: inline-block;
width: 8px;
height: 18px;
background: #06b6d4;
margin-left: 2px;
animation: cursor-blink 1s step-end infinite;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.install-tabs {
display: flex;
gap: 0;
background: linear-gradient(180deg, #334155 0%, #1e293b 100%);
border-radius: 20px 20px 0 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.install-tab {
flex: 1;
min-width: fit-content;
white-space: nowrap;
padding: 12px 20px;
background: transparent;
border: none;
color: #64748b;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.install-tab:hover {
color: #94a3b8;
background: rgba(255, 255, 255, 0.02);
}
.install-tab.active {
color: #e2e8f0;
background: rgba(6, 182, 212, 0.1);
}
.install-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: #06b6d4;
}
.install-tab-content {
display: none;
}
.install-tab-content.active {
display: flex;
}
.terminal-header {
background: linear-gradient(180deg, #334155 0%, #1e293b 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 20px 20px 0 0;
}
.terminal-dot { width: 12px; height: 12px; border-radius: 50%; }
.feature-icon {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.1) 0%, rgba(8, 145, 178, 0.1) 100%);
border: 1px solid rgba(6, 182, 212, 0.2);
border-radius: 16px;
}
.logo-glow {
filter: drop-shadow(0 0 20px rgba(6, 182, 212, 0.3));
}
/* Fitness pulse animation */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
.pulse-ring {
position: absolute;
inset: -8px;
border: 2px solid rgba(6, 182, 212, 0.3);
border-radius: 50%;
animation: pulse-ring 2s ease-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-12px); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-float { animation: float 6s ease-in-out infinite; }
.animate-fade-in-up { opacity: 0; animation: fadeInUp 0.8s ease-out forwards; }
.opacity-0-initial { opacity: 0; }
.stagger-1 { animation-delay: 0.1s !important; }
.stagger-2 { animation-delay: 0.2s !important; }
.stagger-3 { animation-delay: 0.3s !important; }
.stagger-4 { animation-delay: 0.4s !important; }
.stagger-5 { animation-delay: 0.5s !important; }
.stagger-6 { animation-delay: 0.6s !important; }
.bg-pattern {
background-image: radial-gradient(rgba(6, 182, 212, 0.03) 1px, transparent 1px);
background-size: 32px 32px;
}
html.dark .bg-pattern {
background-image: radial-gradient(rgba(6, 182, 212, 0.05) 1px, transparent 1px);
}
/* Heart rate indicator */
.hr-indicator {
position: absolute;
display: flex;
align-items: center;
gap: 6px;
background: rgba(6, 182, 212, 0.1);
border: 1px solid rgba(6, 182, 212, 0.2);
border-radius: 12px;
padding: 6px 12px;
font-size: 12px;
color: #0891b2;
}
html.dark .hr-indicator {
background: rgba(6, 182, 212, 0.15);
color: #22d3ee;
}
@keyframes heartbeat {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.heartbeat { animation: heartbeat 1s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.animate-float, .animate-fade-in-up, [class*="stagger-"], .pulse-ring, .heartbeat {
animation: none !important;
}
.opacity-0-initial { opacity: 1; }
.glass-card:hover { transform: none; }
}
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
</style>
</head>
<body class="min-h-screen text-slate-800 dark:text-slate-200 antialiased overflow-x-hidden bg-pattern">
<!-- Dark Mode Toggle -->
<button class="js-theme-toggle fixed top-6 right-6 z-50 p-3 rounded-2xl bg-white/70 dark:bg-slate-800/70 backdrop-blur-lg border border-gray-200 dark:border-slate-700 shadow-lg hover:shadow-xl transition-all duration-300"
aria-label="Toggle dark mode">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
<!-- Hero Section with Terminal Demo -->
<section class="relative min-h-screen flex items-center justify-center px-6 py-20">
<div class="max-w-5xl mx-auto text-center">
<!-- Logo -->
<div class="mb-8 animate-fade-in-up stagger-1">
<div class="logo-glow inline-block animate-float">
<div class="w-24 h-24 mx-auto bg-garmin-500 rounded-[28%] flex items-center justify-center shadow-lg">
<svg class="w-16 h-16 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.265 12.024a.289.289 0 0 0-.236-.146h-.182a.289.289 0 0 0-.234.146l-1.449 3.025c-.041.079.004.138.094.138h.335c.132 0 .193-.061.228-.134.037-.073.116-.234.13-.266.02-.045.083-.071.175-.071h1.559c.089 0 .148.016.175.071.018.035.098.179.136.256a.24.24 0 0 0 .234.142h.486c.089 0 .13-.069.098-.132-.034-.061-1.549-3.029-1.549-3.029zm-.914 2.224c-.089 0-.132-.067-.094-.148l.571-1.222c.039-.081.1-.081.136 0l.555 1.222c.037.081-.006.148-.096.148H5.351zm12.105-2.201v3.001c0 .083.073.138.163.138h.396c.089 0 .163-.057.163-.146v-2.998c0-.089-.059-.163-.148-.163h-.411c-.09-.001-.163.054-.163.168zm-6.631 1.88c-.051-.073-.022-.154.063-.181 0 0 .342-.102.506-.25.165-.146.246-.36.246-.636a1 1 0 0 0-.096-.457.787.787 0 0 0-.27-.303 1.276 1.276 0 0 0-.423-.171c-.165-.035-.386-.047-.386-.047a8.81 8.81 0 0 0-.325-.008H8.495a.164.164 0 0 0-.163.163v2.998c0 .089.073.146.163.146h.388c.089 0 .163-.057.163-.146v-1.193s.002 0 .002-.002l.738-.002c.089 0 .205.061.258.134l.766 1.077c.071.096.138.132.228.132h.508c.089 0 .104-.085.073-.128-.032-.038-.794-1.126-.794-1.126zm-.311-.61a1.57 1.57 0 0 1-.213.028 8.807 8.807 0 0 1-.325.006h-.763a.164.164 0 0 1-.163-.163v-.608c0-.089.073-.163.163-.163h.762c.089 0 .236.004.325.006 0 0 .114.004.213.028a.629.629 0 0 1 .24.098.358.358 0 0 1 .126.148.473.473 0 0 1 0 .374.352.352 0 0 1-.126.148.617.617 0 0 1-.239.098zm11.803-1.439c-.089 0-.163.059-.163.146v1.919c0 .089-.051.11-.114.047l-1.921-1.992a.376.376 0 0 0-.276-.118h-.362c-.114 0-.163.061-.163.122v3.068c0 .061.059.12.148.12h.362c.089 0 .152-.049.152-.132l.002-2.021c0-.089.051-.11.114-.045l2.004 2.082a.36.36 0 0 0 .279.116h.272a.164.164 0 0 0 .163-.163v-2.986a.164.164 0 0 0-.163-.163h-.334zm-7.835 1.87c-.043.079-.116.077-.159 0l-.939-1.724a.262.262 0 0 0-.236-.146h-.51a.164.164 0 0 0-.163.163v2.996c0 .089.059.15.163.15h.317c.089 0 .154-.057.154-.142 0-.041.002-2.179.004-2.179.004 0 1.173 2.177 1.173 2.177a.105.105 0 0 0 .189 0s1.179-2.173 1.181-2.173c.004 0 .002 2.11.002 2.173 0 .087.069.142.159.142h.364c.089 0 .163-.045.163-.163V12.04a.164.164 0 0 0-.163-.163h-.488a.265.265 0 0 0-.244.142l-.967 1.729zM0 13.529c0 1.616 1.653 1.697 1.984 1.697 1.098 0 1.561-.297 1.58-.309a.29.29 0 0 0 .152-.264v-1.116a.186.186 0 0 0-.187-.187H2.151c-.104 0-.171.083-.171.187v.116c0 .104.067.187.171.187h.797a.14.14 0 0 1 .14.14v.52c-.157.065-.874.274-1.451.136-.836-.199-.901-.89-.901-1.096 0-.173.053-1.043 1.079-1.13.831-.071 1.378.264 1.384.268.098.051.199.014.254-.089l.104-.209c.043-.085.028-.175-.077-.246-.006-.004-.59-.319-1.494-.319C.055 11.813 0 13.354 0 13.529z"/>
</svg>
</div>
</div>
</div>
<!-- Headline -->
<h1 class="text-5xl md:text-7xl font-bold mb-6 tracking-tight animate-fade-in-up stagger-2 text-slate-900 dark:text-white">
<span class="gradient-text">Garmin</span> From Your Terminal
</h1>
<!-- Tagline -->
<p class="text-xl md:text-2xl text-slate-500 dark:text-slate-400 mb-8 max-w-2xl mx-auto leading-relaxed animate-fade-in-up stagger-3 font-mono">
Your fitness data, your way.
</p>
<!-- Terminal Demo -->
<div class="terminal max-w-3xl mx-auto mb-10 text-left animate-fade-in-up stagger-4">
<div class="terminal-header px-4 py-3 flex items-center gap-2">
<div class="terminal-dot bg-red-500"></div>
<div class="terminal-dot bg-yellow-500"></div>
<div class="terminal-dot bg-green-500"></div>
<span class="ml-4 text-sm text-slate-400">garmin-connect-cli</span>
</div>
<div class="p-6 overflow-x-auto scrollbar-hide terminal-content" id="terminal-demo">
<!-- Content populated by JavaScript -->
</div>
</div>
<!-- CTA Buttons -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8 animate-fade-in-up stagger-5">
<a href="#installation" class="btn-primary px-8 py-4 text-lg font-semibold text-white inline-flex items-center gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Install Now
</a>
<a href="https://github.com/eddmann/garmin-connect-cli" class="px-8 py-4 text-lg font-semibold text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors inline-flex items-center gap-3 border border-slate-200 dark:border-slate-700 rounded-2xl hover:border-slate-300 dark:hover:border-slate-600 bg-white/50 dark:bg-slate-800/50 backdrop-blur">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
View on GitHub
</a>
</div>
<!-- Badge -->
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/60 dark:bg-slate-800/60 backdrop-blur border border-slate-200 dark:border-slate-700 text-sm text-slate-500 dark:text-slate-400 animate-fade-in-up stagger-6">
<span class="w-2 h-2 bg-garmin-500 rounded-full animate-pulse"></span>
<span class="font-medium text-garmin-600 dark:text-garmin-400">AI Agent Ready</span>
<span class="text-slate-300 dark:text-slate-600">|</span>
<span>Python 3.10+</span>
</div>
</div>
</section>
<!-- Features Section -->
<section class="relative px-6 py-24">
<div class="max-w-6xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold mb-4 text-slate-900 dark:text-white">Complete access to your fitness data</h2>
<p class="text-slate-500 dark:text-slate-400 text-lg max-w-2xl mx-auto">Everything Garmin Connect has, at your fingertips.</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Feature 1 -->
<div class="glass-card p-8 opacity-0-initial stagger-1">
<div class="feature-icon w-14 h-14 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-garmin-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3 text-slate-900 dark:text-white">All Activity Data</h3>
<p class="text-slate-500 dark:text-slate-400 leading-relaxed">Workouts, runs, rides, swims. Splits, laps, heart rate zones, and detailed metrics.</p>
</div>
<!-- Feature 2 -->
<div class="glass-card p-8 opacity-0-initial stagger-2">
<div class="feature-icon w-14 h-14 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-garmin-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3 text-slate-900 dark:text-white">Daily Health</h3>
<p class="text-slate-500 dark:text-slate-400 leading-relaxed">Sleep quality, body battery, stress levels, steps, and resting heart rate trends.</p>
</div>
<!-- Feature 3 -->
<div class="glass-card p-8 opacity-0-initial stagger-3">
<div class="feature-icon w-14 h-14 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-garmin-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3 text-slate-900 dark:text-white">AI Agent Ready</h3>
<p class="text-slate-500 dark:text-slate-400 leading-relaxed">Works with Claude, Cursor, and other AI assistants. Install the skill and let agents track your fitness.</p>
</div>
<!-- Feature 4 -->
<div class="glass-card p-8 opacity-0-initial stagger-4">
<div class="feature-icon w-14 h-14 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-garmin-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3 text-slate-900 dark:text-white">Training Metrics</h3>
<p class="text-slate-500 dark:text-slate-400 leading-relaxed">VO2 max, HRV status, training readiness, and training load. Track your progress.</p>
</div>
<!-- Feature 5 -->
<div class="glass-card p-8 opacity-0-initial stagger-5">
<div class="feature-icon w-14 h-14 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-garmin-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3 text-slate-900 dark:text-white">Download & Upload</h3>
<p class="text-slate-500 dark:text-slate-400 leading-relaxed">Export activities as GPX/TCX/FIT. Upload workout files from any source.</p>
</div>
<!-- Feature 6 -->
<div class="glass-card p-8 opacity-0-initial stagger-6">
<div class="feature-icon w-14 h-14 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-garmin-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3 text-slate-900 dark:text-white">Script & Automate</h3>
<p class="text-slate-500 dark:text-slate-400 leading-relaxed">Composable with jq, pipes, and Unix tools. JSON, CSV, or tables.</p>
</div>
</div>
</div>
</section>
<!-- Installation Section -->
<section id="installation" class="relative px-6 py-24">
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4 text-slate-900 dark:text-white">Get started in seconds</h2>
<p class="text-slate-500 dark:text-slate-400 text-lg">Choose your preferred installation method.</p>
</div>
<div class="terminal opacity-0-initial stagger-1">
<!-- Tabs -->
<div class="install-tabs">
<button class="install-tab active" data-tab="homebrew">Homebrew</button>
<button class="install-tab" data-tab="script">Install Script</button>
<button class="install-tab" data-tab="uv">uv tool</button>
<button class="install-tab" data-tab="skill">Agent Skill</button>
</div>
<!-- Homebrew Content -->
<div class="install-tab-content active items-center py-4 pl-4 pr-2 md:pl-6 md:pr-4" data-tab-content="homebrew">
<div class="relative flex-1 overflow-hidden">
<div class="overflow-x-auto scrollbar-hide">
<div class="flex items-center gap-2 md:gap-3 whitespace-nowrap pr-6">
<span class="text-garmin-400 font-medium">$</span>
<code class="text-slate-200 text-sm md:text-lg">brew install eddmann/tap/garmin-connect-cli</code>
</div>
</div>
</div>
<button class="js-copy-command ml-2 p-2 rounded-lg hover:bg-white/10 transition-colors group flex-shrink-0" data-command="brew install eddmann/tap/garmin-connect-cli" title="Copy to clipboard">
<svg class="copy-icon w-5 h-5 text-slate-500 group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="check-icon w-5 h-5 text-garmin-400 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
<!-- Install Script Content -->
<div class="install-tab-content items-center py-4 pl-4 pr-2 md:pl-6 md:pr-4" data-tab-content="script">
<div class="relative flex-1 overflow-hidden">
<div class="overflow-x-auto scrollbar-hide">
<div class="flex items-center gap-2 md:gap-3 whitespace-nowrap pr-6">
<span class="text-garmin-400 font-medium">$</span>
<code class="text-slate-200 text-sm md:text-base">curl -fsSL https://raw.githubusercontent.com/eddmann/garmin-connect-cli/main/install.sh | sh</code>
</div>
</div>
</div>
<button class="js-copy-command ml-2 p-2 rounded-lg hover:bg-white/10 transition-colors group flex-shrink-0" data-command="curl -fsSL https://raw.githubusercontent.com/eddmann/garmin-connect-cli/main/install.sh | sh" title="Copy to clipboard">
<svg class="copy-icon w-5 h-5 text-slate-500 group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="check-icon w-5 h-5 text-garmin-400 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
<!-- uv tool Content -->
<div class="install-tab-content items-center py-4 pl-4 pr-2 md:pl-6 md:pr-4" data-tab-content="uv">
<div class="relative flex-1 overflow-hidden">
<div class="overflow-x-auto scrollbar-hide">
<div class="flex items-center gap-2 md:gap-3 whitespace-nowrap pr-6">
<span class="text-garmin-400 font-medium">$</span>
<code class="text-slate-200 text-sm md:text-base">uvx garmin-connect-cli --help</code>
</div>
</div>
</div>
<button class="js-copy-command ml-2 p-2 rounded-lg hover:bg-white/10 transition-colors group flex-shrink-0" data-command="uvx garmin-connect-cli --help" title="Copy to clipboard">
<svg class="copy-icon w-5 h-5 text-slate-500 group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="check-icon w-5 h-5 text-garmin-400 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
<!-- Agent Skill Content -->
<div class="install-tab-content items-center py-4 pl-4 pr-2 md:pl-6 md:pr-4" data-tab-content="skill">
<div class="relative flex-1 overflow-hidden">
<div class="overflow-x-auto scrollbar-hide">
<div class="flex items-center gap-2 md:gap-3 whitespace-nowrap pr-6">
<span class="text-garmin-400 font-medium">$</span>
<code class="text-slate-200 text-sm md:text-base">curl -fsSL https://raw.githubusercontent.com/eddmann/garmin-connect-cli/main/install-skill.sh | sh</code>
</div>
</div>
</div>
<button class="js-copy-command ml-2 p-2 rounded-lg hover:bg-white/10 transition-colors group flex-shrink-0" data-command="curl -fsSL https://raw.githubusercontent.com/eddmann/garmin-connect-cli/main/install-skill.sh | sh" title="Copy to clipboard">
<svg class="copy-icon w-5 h-5 text-slate-500 group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="check-icon w-5 h-5 text-garmin-400 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
<p class="text-center mt-8 text-slate-400">
Or download directly from
<a href="https://github.com/eddmann/garmin-connect-cli/releases" class="text-garmin-500 hover:text-garmin-600 underline underline-offset-4 transition-colors">GitHub Releases</a>
</p>
</div>
</section>
<!-- Footer -->
<footer class="relative px-6 py-16 border-t border-slate-200 dark:border-slate-800">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8 flex items-center justify-center gap-3">
<div class="w-10 h-10 bg-garmin-500 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.265 12.024a.289.289 0 0 0-.236-.146h-.182a.289.289 0 0 0-.234.146l-1.449 3.025c-.041.079.004.138.094.138h.335c.132 0 .193-.061.228-.134.037-.073.116-.234.13-.266.02-.045.083-.071.175-.071h1.559c.089 0 .148.016.175.071.018.035.098.179.136.256a.24.24 0 0 0 .234.142h.486c.089 0 .13-.069.098-.132-.034-.061-1.549-3.029-1.549-3.029zm-.914 2.224c-.089 0-.132-.067-.094-.148l.571-1.222c.039-.081.1-.081.136 0l.555 1.222c.037.081-.006.148-.096.148H5.351zm12.105-2.201v3.001c0 .083.073.138.163.138h.396c.089 0 .163-.057.163-.146v-2.998c0-.089-.059-.163-.148-.163h-.411c-.09-.001-.163.054-.163.168zm-6.631 1.88c-.051-.073-.022-.154.063-.181 0 0 .342-.102.506-.25.165-.146.246-.36.246-.636a1 1 0 0 0-.096-.457.787.787 0 0 0-.27-.303 1.276 1.276 0 0 0-.423-.171c-.165-.035-.386-.047-.386-.047a8.81 8.81 0 0 0-.325-.008H8.495a.164.164 0 0 0-.163.163v2.998c0 .089.073.146.163.146h.388c.089 0 .163-.057.163-.146v-1.193s.002 0 .002-.002l.738-.002c.089 0 .205.061.258.134l.766 1.077c.071.096.138.132.228.132h.508c.089 0 .104-.085.073-.128-.032-.038-.794-1.126-.794-1.126zm-.311-.61a1.57 1.57 0 0 1-.213.028 8.807 8.807 0 0 1-.325.006h-.763a.164.164 0 0 1-.163-.163v-.608c0-.089.073-.163.163-.163h.762c.089 0 .236.004.325.006 0 0 .114.004.213.028a.629.629 0 0 1 .24.098.358.358 0 0 1 .126.148.473.473 0 0 1 0 .374.352.352 0 0 1-.126.148.617.617 0 0 1-.239.098zm11.803-1.439c-.089 0-.163.059-.163.146v1.919c0 .089-.051.11-.114.047l-1.921-1.992a.376.376 0 0 0-.276-.118h-.362c-.114 0-.163.061-.163.122v3.068c0 .061.059.12.148.12h.362c.089 0 .152-.049.152-.132l.002-2.021c0-.089.051-.11.114-.045l2.004 2.082a.36.36 0 0 0 .279.116h.272a.164.164 0 0 0 .163-.163v-2.986a.164.164 0 0 0-.163-.163h-.334zm-7.835 1.87c-.043.079-.116.077-.159 0l-.939-1.724a.262.262 0 0 0-.236-.146h-.51a.164.164 0 0 0-.163.163v2.996c0 .089.059.15.163.15h.317c.089 0 .154-.057.154-.142 0-.041.002-2.179.004-2.179.004 0 1.173 2.177 1.173 2.177a.105.105 0 0 0 .189 0s1.179-2.173 1.181-2.173c.004 0 .002 2.11.002 2.173 0 .087.069.142.159.142h.364c.089 0 .163-.045.163-.163V12.04a.164.164 0 0 0-.163-.163h-.488a.265.265 0 0 0-.244.142l-.967 1.729zM0 13.529c0 1.616 1.653 1.697 1.984 1.697 1.098 0 1.561-.297 1.58-.309a.29.29 0 0 0 .152-.264v-1.116a.186.186 0 0 0-.187-.187H2.151c-.104 0-.171.083-.171.187v.116c0 .104.067.187.171.187h.797a.14.14 0 0 1 .14.14v.52c-.157.065-.874.274-1.451.136-.836-.199-.901-.89-.901-1.096 0-.173.053-1.043 1.079-1.13.831-.071 1.378.264 1.384.268.098.051.199.014.254-.089l.104-.209c.043-.085.028-.175-.077-.246-.006-.004-.59-.319-1.494-.319C.055 11.813 0 13.354 0 13.529z"/>
</svg>
</div>
<span class="text-2xl font-bold gradient-text">Garmin Connect CLI</span>
</div>
<div class="flex items-center justify-center gap-6 mb-8">
<a href="https://github.com/eddmann/garmin-connect-cli" class="text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors inline-flex items-center gap-2">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</a>
<span class="text-slate-300 dark:text-slate-600">|</span>
<a href="https://github.com/eddmann/garmin-connect-cli/releases" class="text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors">Releases</a>
<span class="text-slate-300 dark:text-slate-600">|</span>
<a href="https://github.com/eddmann/garmin-connect-cli/issues" class="text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors">Issues</a>
</div>
<p class="text-slate-400 text-sm">
Built by <a href="https://eddmann.com" class="text-garmin-500 hover:text-garmin-600 transition-colors">Edd Mann</a>
</p>
</div>
</footer>
<script>
// Copy to clipboard
document.querySelectorAll('.js-copy-command').forEach(button => {
button.addEventListener('click', () => {
const text = button.dataset.command;
navigator.clipboard.writeText(text);
const copyIcon = button.querySelector('.copy-icon');
const checkIcon = button.querySelector('.check-icon');
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
});
});
// Installation tabs
document.querySelectorAll('.install-tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
document.querySelectorAll('.install-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.install-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.querySelector(`[data-tab-content="${tabName}"]`).classList.add('active');
});
});
// Intersection Observer for scroll animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.remove('opacity-0-initial');
entry.target.classList.add('animate-fade-in-up');
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
document.querySelectorAll('.opacity-0-initial').forEach(el => observer.observe(el));
// Animated terminal demo
(function() {
const TYPING_SPEED = 40;
const OUTPUT_DELAY = 300;
const PAUSE_BETWEEN_DEMOS = 3000;
const terminalDemo = document.getElementById('terminal-demo');
const demos = [
{
command: 'garmin-connect activities list -f human --limit 3',
output: `<span class="text-slate-400">ACTIVITY NAME START TIME DISTANCE</span>
<span class="text-slate-200">Morning Run</span> <span class="text-slate-300">2024-12-29 07:15</span> <span class="text-garmin-300">8540.2</span>
<span class="text-slate-200">Evening Cycle</span> <span class="text-slate-300">2024-12-28 17:30</span> <span class="text-garmin-300">45230.0</span>
<span class="text-slate-200">Strength</span> <span class="text-slate-300">2024-12-27 06:00</span> <span class="text-slate-400">-</span>`
},
{
command: 'garmin-connect health sleep | jq .dailySleepDTO.sleepTimeSeconds',
output: `<span class="text-yellow-400">27120</span>`
},
{
command: 'garmin-connect athlete stats | jq .totalSteps',
output: `<span class="text-yellow-400">8472</span>`
},
{
command: 'garmin-connect training readiness | jq .score',
output: `<span class="text-yellow-400">78</span>`
}
];
let currentDemo = 0;
let charIndex = 0;
function typeCommand() {
const demo = demos[currentDemo];
const commandEl = terminalDemo.querySelector('.command-text');
if (charIndex < demo.command.length) {
commandEl.textContent = demo.command.substring(0, charIndex + 1);
charIndex++;
setTimeout(typeCommand, TYPING_SPEED);
} else {
setTimeout(showOutput, OUTPUT_DELAY);
}
}
function showOutput() {
const demo = demos[currentDemo];
const outputEl = terminalDemo.querySelector('.output-content');
outputEl.innerHTML = demo.output;
outputEl.classList.add('visible');
setTimeout(nextDemo, PAUSE_BETWEEN_DEMOS);
}
function nextDemo() {
currentDemo = (currentDemo + 1) % demos.length;
charIndex = 0;
renderTerminal();
}
function renderTerminal() {
terminalDemo.innerHTML = `
<div class="space-y-4 text-sm md:text-base">
<div class="flex items-center">
<span class="text-garmin-400">$</span>
<span class="text-slate-300 ml-2 command-text"></span><span class="cursor"></span>
</div>
<pre class="text-slate-400 text-xs md:text-sm leading-relaxed terminal-line output-content"></pre>
</div>
`;
setTimeout(typeCommand, 500);
}
renderTerminal();
})();
// Theme toggle
(function() {
const STORAGE_KEY = 'theme-preference';
function getThemePreference() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function setTheme(theme) {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
setTheme(getThemePreference());
document.querySelector('.js-theme-toggle').addEventListener('click', () => {
const current = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, next);
setTheme(next);
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem(STORAGE_KEY)) {
setTheme(e.matches ? 'dark' : 'light');
}
});
})();
</script>
</body>
</html>
"""garmin-connect-cli: Garmin Connect from your terminal. Pipe it, script it, automate it."""
__version__ = "0.0.0"
"""Allow running as `python -m garmin_connect_cli`."""
from garmin_connect_cli.cli import app
if __name__ == "__main__":
app()
"""Main CLI entry point."""
from __future__ import annotations
import sys
from typing import Annotated
import typer
from garmin_connect_cli import __version__
from garmin_connect_cli.commands import (
activities,
athlete,
auth,
context,
health,
training,
weight,
)
from garmin_connect_cli.core import state
from garmin_connect_cli.output import OutputFormat
# Exit codes
EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_AUTH_ERROR = 2
app = typer.Typer(
name="garmin-connect",
help="Garmin Connect from your terminal. Pipe it, script it, automate it.",
no_args_is_help=True,
add_completion=True,
rich_markup_mode="rich",
)
def version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
print(f"garmin-connect {__version__}")
raise typer.Exit()
@app.callback()
def main(
format: Annotated[
OutputFormat,
typer.Option(
"--format",
"-f",
help="Output format",
envvar="GARMIN_FORMAT",
),
] = OutputFormat.json,
fields: Annotated[
str | None,
typer.Option(
"--fields",
help="Comma-separated list of fields to include in output",
),
] = None,
no_header: Annotated[
bool,
typer.Option(
"--no-header",
help="Omit header row in CSV/TSV output",
),
] = False,
verbose: Annotated[
bool,
typer.Option(
"--verbose",
"-v",
help="Verbose output to stderr",
),
] = False,
quiet: Annotated[
bool,
typer.Option(
"--quiet",
"-q",
help="Suppress non-essential output",
),
] = False,
config: Annotated[
str | None,
typer.Option(
"--config",
"-c",
help="Path to config file",
envvar="GARMIN_CONFIG",
),
] = None,
profile: Annotated[
str | None,
typer.Option(
"--profile",
"-p",
help="Named profile to use",
envvar="GARMIN_PROFILE",
),
] = None,
version: Annotated[
bool,
typer.Option(
"--version",
"-V",
callback=version_callback,
is_eager=True,
help="Show version and exit",
),
] = False,
) -> None:
"""Global options applied to all commands."""
# Mutual exclusivity check
if verbose and quiet:
print("error: --verbose and --quiet are mutually exclusive", file=sys.stderr)
raise typer.Exit(EXIT_ERROR)
state.format = format
state.fields = fields.split(",") if fields else None
state.no_header = no_header
state.verbose = verbose
state.quiet = quiet
state.config_path = config
state.profile = profile
# Register subcommands
app.add_typer(auth.app, name="auth", help="Authentication commands")
app.add_typer(athlete.app, name="athlete", help="Athlete profile and stats")
app.add_typer(activities.app, name="activities", help="Activity management")
app.add_typer(health.app, name="health", help="Health data (sleep, HR, steps, etc.)")
app.add_typer(training.app, name="training", help="Training metrics (status, VO2max, HRV, etc.)")
app.add_typer(weight.app, name="weight", help="Weight and body composition")
app.add_typer(context.app, name="context", help="Aggregated context for LLMs")
def error(message: str, exit_code: int = EXIT_ERROR) -> None:
"""Print error message to stderr and exit."""
print(f"error: {message}", file=sys.stderr)
raise typer.Exit(exit_code)
def auth_error(message: str) -> None:
"""Print auth error message to stderr and exit."""
error(message, EXIT_AUTH_ERROR)
if __name__ == "__main__":
app()
"""Garmin Connect client wrapper with token management."""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any, cast
import typer
from garminconnect import Garmin
from garmin_connect_cli.config import Config, get_token_dir
if TYPE_CHECKING:
from collections.abc import Callable
class GarminClient:
"""Wrapper around garminconnect.Garmin with token management."""
def __init__(self, config: Config, profile: str | None = None):
"""Initialize the Garmin client.
Args:
config: Application configuration
profile: Optional profile name for multi-account support
"""
self.config = config
self.profile = profile
self.token_dir = get_token_dir(profile)
self._client: Garmin | None = None
@property
def client(self) -> Garmin:
"""Get or create the Garmin client."""
if self._client is None:
self._client = Garmin()
return self._client
def is_authenticated(self) -> bool:
"""Check if we have stored tokens."""
token_file = self.token_dir / "garmin_tokens.json"
return token_file.exists()
def ensure_authenticated(self) -> None:
"""Ensure we have valid authentication, loading tokens if available."""
if not self.is_authenticated():
print(
"error: Not authenticated. Run 'garmin-connect auth login' first.",
file=sys.stderr,
)
raise typer.Exit(2)
try:
# Create client and load tokens from tokenstore
self._client = Garmin()
self._client.login(str(self.token_dir))
except Exception as e:
print(f"error: Authentication failed: {e}", file=sys.stderr)
print(
"Try running 'garmin-connect auth login' to re-authenticate.",
file=sys.stderr,
)
raise typer.Exit(2) from None
def login(
self,
email: str,
password: str,
mfa_callback: Callable[[], str] | None = None,
) -> bool:
"""Perform login with email/password.
Args:
email: Garmin account email
password: Garmin account password
mfa_callback: Optional callback for MFA code input
Returns:
True if login successful
"""
try:
# Ensure token directory exists
self.token_dir.mkdir(parents=True, exist_ok=True)
# Initialize client with credentials
self._client = Garmin(
email=email,
password=password,
prompt_mfa=mfa_callback,
)
# Attempt login
self._client.login()
# Save tokens for future use
self._client.client.dump(str(self.token_dir))
return True
except Exception as e:
print(f"error: Login failed: {e}", file=sys.stderr)
return False
def logout(self) -> None:
"""Clear stored tokens."""
import shutil
if self.token_dir.exists():
shutil.rmtree(self.token_dir)
# Profile methods
def get_full_name(self) -> str | None:
"""Get user's full name."""
self.ensure_authenticated()
return self.client.get_full_name()
def get_user_profile(self) -> dict[str, Any]:
"""Get user profile."""
self.ensure_authenticated()
return self.client.get_user_profile()
def get_unit_system(self) -> str | None:
"""Get user's unit system preference."""
self.ensure_authenticated()
return self.client.get_unit_system()
# Activity methods
def get_activities(self, start: int = 0, limit: int = 30) -> list[dict[str, Any]]:
"""Get activities with pagination.
Args:
start: Starting index
limit: Maximum number of activities
Returns:
List of activity dictionaries
"""
self.ensure_authenticated()
return cast(list[dict[str, Any]], self.client.get_activities(start=start, limit=limit))
def get_activities_by_date(
self,
start_date: str,
end_date: str,
activity_type: str | None = None,
) -> list[dict[str, Any]]:
"""Get activities within a date range.
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
activity_type: Optional activity type filter
Returns:
List of activity dictionaries
"""
self.ensure_authenticated()
return self.client.get_activities_by_date(
startdate=start_date,
enddate=end_date,
activitytype=activity_type,
)
def get_activity(self, activity_id: int) -> dict[str, Any]:
"""Get a single activity by ID."""
self.ensure_authenticated()
return self.client.get_activity(str(activity_id))
def get_activity_details(self, activity_id: int) -> dict[str, Any]:
"""Get detailed activity data including metrics."""
self.ensure_authenticated()
return self.client.get_activity_details(str(activity_id))
def get_activity_splits(self, activity_id: int) -> dict[str, Any]:
"""Get activity splits/laps."""
self.ensure_authenticated()
return self.client.get_activity_splits(str(activity_id))
def download_activity(self, activity_id: int, dl_fmt: str = "TCX") -> bytes:
"""Download activity in specified format.
Args:
activity_id: Activity ID
dl_fmt: Download format (TCX, GPX, ORIGINAL, CSV)
Returns:
Activity data as bytes
"""
self.ensure_authenticated()
fmt_map = {
"TCX": Garmin.ActivityDownloadFormat.TCX,
"GPX": Garmin.ActivityDownloadFormat.GPX,
"ORIGINAL": Garmin.ActivityDownloadFormat.ORIGINAL,
"CSV": Garmin.ActivityDownloadFormat.CSV,
}
fmt = fmt_map.get(dl_fmt.upper(), Garmin.ActivityDownloadFormat.TCX)
return self.client.download_activity(str(activity_id), dl_fmt=fmt)
def upload_activity(self, file_path: str) -> dict[str, Any]:
"""Upload an activity file.
Args:
file_path: Path to activity file (FIT, GPX, TCX)
Returns:
Upload response
"""
self.ensure_authenticated()
return self.client.upload_activity(file_path)
def delete_activity(self, activity_id: int) -> None:
"""Delete an activity."""
self.ensure_authenticated()
self.client.delete_activity(str(activity_id))
# Stats methods
def get_stats(self, date_str: str) -> dict[str, Any]:
"""Get daily stats.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Stats dictionary
"""
self.ensure_authenticated()
return self.client.get_stats(date_str)
def get_user_summary(self, date_str: str) -> dict[str, Any]:
"""Get user summary for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Summary dictionary
"""
self.ensure_authenticated()
return self.client.get_user_summary(date_str)
def get_stats_and_body(self, date_str: str) -> dict[str, Any]:
"""Get comprehensive daily stats and body metrics.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Combined stats and body data
"""
self.ensure_authenticated()
return self.client.get_stats_and_body(date_str)
# Health methods
def get_sleep_data(self, date_str: str) -> dict[str, Any]:
"""Get sleep data for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Sleep data dictionary
"""
self.ensure_authenticated()
return self.client.get_sleep_data(date_str)
def get_heart_rates(self, date_str: str) -> dict[str, Any]:
"""Get heart rate data for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Heart rate data dictionary
"""
self.ensure_authenticated()
return self.client.get_heart_rates(date_str)
def get_steps_data(self, date_str: str) -> dict[str, Any]:
"""Get steps data for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Steps data dictionary
"""
self.ensure_authenticated()
return cast(dict[str, Any], self.client.get_steps_data(date_str))
def get_rhr_day(self, date_str: str) -> dict[str, Any]:
"""Get resting heart rate for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
RHR data dictionary
"""
self.ensure_authenticated()
return self.client.get_rhr_day(date_str)
def get_stress_data(self, date_str: str) -> dict[str, Any]:
"""Get stress data for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Stress data dictionary
"""
self.ensure_authenticated()
return self.client.get_stress_data(date_str)
def get_body_battery(self, date_str: str) -> list[dict[str, Any]]:
"""Get body battery data for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Body battery data list
"""
self.ensure_authenticated()
return self.client.get_body_battery(date_str)
# Training metrics methods
def get_training_status(self, date_str: str) -> dict[str, Any]:
"""Get training status for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Training status data
"""
self.ensure_authenticated()
return self.client.get_training_status(date_str)
def get_training_readiness(self, date_str: str) -> dict[str, Any]:
"""Get training readiness for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Training readiness data
"""
self.ensure_authenticated()
return self.client.get_training_readiness(date_str)
def get_max_metrics(self, date_str: str) -> dict[str, Any]:
"""Get max metrics (VO2 max) for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Max metrics data including VO2 max
"""
self.ensure_authenticated()
return self.client.get_max_metrics(date_str)
def get_lactate_threshold(self) -> dict[str, Any]:
"""Get lactate threshold data.
Returns:
Lactate threshold data (HR, pace, power)
"""
self.ensure_authenticated()
return self.client.get_lactate_threshold()
def get_endurance_score(self, date_str: str) -> dict[str, Any]:
"""Get endurance score for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Endurance score data
"""
self.ensure_authenticated()
return self.client.get_endurance_score(date_str)
def get_hill_score(self, date_str: str) -> dict[str, Any]:
"""Get hill score for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Hill score data
"""
self.ensure_authenticated()
return self.client.get_hill_score(date_str)
def get_hrv_data(self, date_str: str) -> dict[str, Any]:
"""Get HRV data for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
HRV data
"""
self.ensure_authenticated()
return self.client.get_hrv_data(date_str) or {}
def get_fitnessage_data(self, date_str: str) -> dict[str, Any]:
"""Get fitness age data.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Fitness age data
"""
self.ensure_authenticated()
return self.client.get_fitnessage_data(date_str)
# Weight and body composition methods
def get_weigh_ins(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
"""Get weight entries between dates.
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
Returns:
List of weight entries
"""
self.ensure_authenticated()
return cast(list[dict[str, Any]], self.client.get_weigh_ins(start_date, end_date))
def get_daily_weigh_ins(self, date_str: str) -> dict[str, Any]:
"""Get weight for a specific date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Weight data for the date
"""
self.ensure_authenticated()
return self.client.get_daily_weigh_ins(date_str)
def get_body_composition(self, date_str: str) -> dict[str, Any]:
"""Get body composition data for a date.
Args:
date_str: Date in YYYY-MM-DD format
Returns:
Body composition data (fat %, muscle, bone, water)
"""
self.ensure_authenticated()
return self.client.get_body_composition(date_str)
def add_weigh_in(
self, weight: float, unitKey: str = "kg", date: str | None = None
) -> dict[str, Any]:
"""Add a weight entry.
Args:
weight: Weight value
unitKey: Unit (kg or lb)
date: Date in YYYY-MM-DD format
Returns:
Result of the operation
"""
self.ensure_authenticated()
return self.client.add_weigh_in(weight=weight, unitKey=unitKey, timestamp=date or "") or {}
def delete_weigh_in(self, pk: int, date_str: str) -> None:
"""Delete a weight entry by primary key.
Args:
pk: Weight entry primary key
date_str: Date of the weight entry in YYYY-MM-DD format
"""
self.ensure_authenticated()
self.client.delete_weigh_in(str(pk), date_str)
def delete_weigh_ins(self, date_str: str) -> None:
"""Delete all weight entries for a date.
Args:
date_str: Date in YYYY-MM-DD format
"""
self.ensure_authenticated()
self.client.delete_weigh_ins(date_str, delete_all=True)
def get_client(config: Config | None = None, profile: str | None = None) -> GarminClient:
"""Get a configured Garmin client.
Args:
config: Optional config, loads default if not provided
profile: Optional profile name
Returns:
Configured GarminClient
"""
if config is None:
config = Config.load()
return GarminClient(config, profile)
"""Command modules for garmin-connect-cli."""
"""Activity commands."""
from __future__ import annotations
import sys
from datetime import date, timedelta
from pathlib import Path
from typing import Annotated
import typer
from garmin_connect_cli.client import GarminClient
from garmin_connect_cli.core import emit, emit_result, with_client
app = typer.Typer(no_args_is_help=True)
@app.command("list")
@with_client
def list_activities(
client: GarminClient,
limit: Annotated[
int,
typer.Option("--limit", "-n", help="Maximum number of activities"),
] = 30,
start: Annotated[
int,
typer.Option("--start", "-s", help="Start index for pagination"),
] = 0,
after: Annotated[
str | None,
typer.Option("--after", "-a", help="Only activities after this date (YYYY-MM-DD)"),
] = None,
before: Annotated[
str | None,
typer.Option("--before", "-b", help="Only activities before this date (YYYY-MM-DD)"),
] = None,
activity_type: Annotated[
str | None,
typer.Option("--type", "-t", help="Filter by activity type (running, cycling, etc.)"),
] = None,
) -> None:
"""List activities.
Returns activities in reverse chronological order.
Examples:
garmin-connect activities list --limit 10
garmin-connect activities list --after 2025-01-01 --type running
garmin-connect activities list | jq '.[].activityName'
"""
if after or before or activity_type:
# Use date range query
end_date = before or date.today().isoformat()
start_date = after or (date.today() - timedelta(days=365)).isoformat()
activities = client.get_activities_by_date(
start_date=start_date,
end_date=end_date,
activity_type=activity_type,
)
# Apply limit manually for date-based query
activities = activities[:limit]
else:
activities = client.get_activities(start=start, limit=limit)
emit(activities)
@app.command("get")
@with_client
def get_activity(
client: GarminClient,
activity_id: Annotated[int, typer.Argument(help="Activity ID")],
details: Annotated[
bool,
typer.Option("--details", "-d", help="Include detailed metrics"),
] = False,
) -> None:
"""Get a single activity by ID.
Examples:
garmin-connect activities get 12345678
garmin-connect activities get 12345678 --details
"""
if details:
activity = client.get_activity_details(activity_id)
else:
activity = client.get_activity(activity_id)
emit(activity)
@app.command("splits")
@with_client
def get_splits(
client: GarminClient,
activity_id: Annotated[int, typer.Argument(help="Activity ID")],
) -> None:
"""Get activity splits (lap data).
Examples:
garmin-connect activities splits 12345678
"""
splits = client.get_activity_splits(activity_id)
emit(splits)
@app.command("download")
@with_client
def download_activity(
client: GarminClient,
activity_id: Annotated[int, typer.Argument(help="Activity ID")],
dl_format: Annotated[
str,
typer.Option("--format", "-f", help="Download format: TCX, GPX, ORIGINAL (FIT zip), CSV"),
] = "TCX",
output_path: Annotated[
str | None,
typer.Option("--output", "-o", help="Output file path"),
] = None,
) -> None:
"""Download activity data file.
Examples:
garmin-connect activities download 12345678 --format GPX
garmin-connect activities download 12345678 -o activity.tcx
"""
data = client.download_activity(activity_id, dl_fmt=dl_format.upper())
if output_path:
Path(output_path).write_bytes(data)
emit_result(
{"path": output_path, "bytes": len(data)},
f"Downloaded to {output_path}",
)
else:
# Write to stdout for piping
sys.stdout.buffer.write(data)
@app.command("upload")
@with_client
def upload_activity(
client: GarminClient,
file_path: Annotated[str, typer.Argument(help="Path to activity file (FIT, GPX, TCX)")],
) -> None:
"""Upload an activity file.
Examples:
garmin-connect activities upload morning_run.fit
garmin-connect activities upload workout.gpx
"""
if not Path(file_path).exists():
print(f"error: File not found: {file_path}", file=sys.stderr)
raise typer.Exit(1)
response = client.upload_activity(file_path)
successes = response.get("detailedImportResult", {}).get("successes", [{}])
activity_id = successes[0].get("internalId", "unknown") if successes else "unknown"
emit_result(response, f"Uploaded: activity {activity_id}")
@app.command("delete")
@with_client
def delete_activity(
client: GarminClient,
activity_id: Annotated[int, typer.Argument(help="Activity ID")],
force: Annotated[
bool,
typer.Option("--force", "-f", help="Skip confirmation"),
] = False,
) -> None:
"""Delete an activity.
Examples:
garmin-connect activities delete 12345678
garmin-connect activities delete 12345678 --force
"""
if not force:
confirm = typer.confirm(f"Delete activity {activity_id}?")
if not confirm:
raise typer.Abort()
client.delete_activity(activity_id)
emit_result({"activity_id": activity_id}, f"Activity {activity_id} deleted")
"""Athlete profile commands."""
from __future__ import annotations
from datetime import date
from typing import Annotated
import typer
from garmin_connect_cli.client import GarminClient
from garmin_connect_cli.core import emit, with_client
app = typer.Typer(invoke_without_command=True)
@app.callback(invoke_without_command=True)
@with_client
def athlete_profile(client: GarminClient, ctx: typer.Context) -> None:
"""Get athlete profile.
When called without a subcommand, returns the user profile.
Examples:
garmin-connect athlete
garmin-connect athlete | jq '.displayName'
"""
if ctx.invoked_subcommand is not None:
return
profile = client.get_user_profile()
emit(profile)
@app.command("stats")
@with_client
def stats(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for stats (YYYY-MM-DD, default: today)"),
] = None,
) -> None:
"""Get daily statistics.
Examples:
garmin-connect athlete stats
garmin-connect athlete stats --date 2025-01-01
garmin-connect athlete stats | jq '.totalSteps'
"""
target_date = date_str or date.today().isoformat()
stats_data = client.get_user_summary(target_date)
emit(stats_data)
@app.command("summary")
@with_client
def summary(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for summary (YYYY-MM-DD, default: today)"),
] = None,
) -> None:
"""Get comprehensive daily summary with body metrics.
Examples:
garmin-connect athlete summary
garmin-connect athlete summary --date 2025-01-01
"""
target_date = date_str or date.today().isoformat()
summary_data = client.get_stats_and_body(target_date)
emit(summary_data)
"""Authentication commands."""
from __future__ import annotations
from typing import Annotated
import typer
from garmin_connect_cli.client import get_client
from garmin_connect_cli.config import Config, get_config_path, get_credentials, get_token_dir
from garmin_connect_cli.core import emit
from garmin_connect_cli.output import OutputFormat
app = typer.Typer(no_args_is_help=True)
@app.command("login")
def login(
email: Annotated[
str | None,
typer.Option(
"--email",
"-e",
help="Garmin account email (or set GARMIN_EMAIL env var)",
),
] = None,
password: Annotated[
str | None,
typer.Option(
"--password",
"-p",
help="Garmin account password (or set GARMIN_PASSWORD env var)",
hide_input=True,
),
] = None,
profile: Annotated[
str | None,
typer.Option(
"--profile",
help="Profile name for multi-account support",
),
] = None,
) -> None:
"""Authenticate with Garmin Connect.
Logs in with email/password and stores OAuth tokens for future use.
Tokens are valid for approximately one year.
Examples:
garmin-connect auth login --email user@example.com
garmin-connect auth login # Uses GARMIN_EMAIL/GARMIN_PASSWORD env vars
"""
# Get credentials from args or environment
env_email, env_password = get_credentials()
final_email = email or env_email
final_password = password or env_password
if not final_email:
final_email = typer.prompt("Email")
if not final_password:
final_password = typer.prompt("Password", hide_input=True)
config = Config.load()
client = get_client(config, profile)
def mfa_callback() -> str:
return typer.prompt("Enter MFA code")
success = client.login(final_email, final_password, mfa_callback)
if not success:
raise typer.Exit(2)
# Optionally save email to profile config
if profile:
from garmin_connect_cli.config import ProfileConfig
config.profiles[profile] = ProfileConfig(email=final_email)
config.save()
# Output result
try:
full_name = client.get_full_name()
except Exception:
full_name = None
output_data = {
"authenticated": True,
"full_name": full_name,
"email": final_email,
"token_dir": str(get_token_dir(profile)),
}
emit(output_data)
@app.command("logout")
def logout(
profile: Annotated[
str | None,
typer.Option(
"--profile",
help="Profile to log out",
),
] = None,
) -> None:
"""Log out and clear stored tokens.
Examples:
garmin-connect auth logout
garmin-connect auth logout --profile work
"""
config = Config.load()
client = get_client(config, profile)
client.logout()
emit({"authenticated": False})
@app.command("status")
def status(
format: Annotated[
OutputFormat,
typer.Option("--format", "-f", help="Output format"),
] = OutputFormat.json,
profile: Annotated[
str | None,
typer.Option(
"--profile",
help="Profile to check",
),
] = None,
) -> None:
"""Show current authentication status.
Examples:
garmin-connect auth status
garmin-connect auth status --format human
"""
from garmin_connect_cli.output import output
config = Config.load()
client = get_client(config, profile)
data = {
"authenticated": client.is_authenticated(),
"token_dir": str(get_token_dir(profile)),
"config_path": str(get_config_path()),
}
if client.is_authenticated():
try:
client.ensure_authenticated()
data["full_name"] = client.get_full_name()
except typer.Exit:
data["authenticated"] = False
data["error"] = "Tokens expired or invalid"
# Use explicit format since this command has its own --format option
output(data, format=format)
"""Context command - aggregated data for LLM prompts."""
from __future__ import annotations
import sys
from datetime import date
from typing import Annotated
import typer
from garmin_connect_cli.client import get_client
from garmin_connect_cli.core import emit, state
def _log_error(message: str, exc: Exception) -> None:
"""Log error to stderr if verbose mode is enabled."""
if state.verbose:
print(f"warning: {message}: {exc}", file=sys.stderr)
app = typer.Typer(invoke_without_command=True)
@app.callback(invoke_without_command=True)
def context(
ctx: typer.Context,
activities_limit: Annotated[
int,
typer.Option(
"--activities",
"-a",
help="Number of recent activities to include",
),
] = 5,
include_health: Annotated[
bool,
typer.Option(
"--health/--no-health",
help="Include health metrics",
),
] = True,
include_stats: Annotated[
bool,
typer.Option(
"--stats/--no-stats",
help="Include daily stats",
),
] = True,
include_training: Annotated[
bool,
typer.Option(
"--training/--no-training",
help="Include training metrics (status, readiness, VO2max)",
),
] = True,
include_weight: Annotated[
bool,
typer.Option(
"--weight/--no-weight",
help="Include weight and body composition",
),
] = True,
focus: Annotated[
str | None,
typer.Option(
"--focus",
"-f",
help="Focus area: activities, stats, health, training, weight (comma-separated)",
),
] = None,
) -> None:
"""Get aggregated context for LLM prompts.
Returns profile, stats, health metrics, training data, weight,
and recent activities in a single call - optimized for LLM context windows.
Examples:
garmin-connect context
garmin-connect context --activities 10
garmin-connect context --focus stats,health,training
garmin-connect context --no-health --no-weight
"""
if ctx.invoked_subcommand is not None:
return
# Parse focus areas
focus_areas = set(focus.split(",")) if focus else None
# Initialize client
from garmin_connect_cli.config import Config
config = Config.load(state.config_path)
client = get_client(config, state.profile)
result = {}
today = date.today().isoformat()
# Always include basic profile info
try:
profile = client.get_user_profile()
result["profile"] = {
"displayName": profile.get("displayName"),
"fullName": client.get_full_name(),
"profileImageUrl": profile.get("profileImageUrlLarge"),
}
except Exception as e:
_log_error("Failed to fetch profile", e)
result["profile"] = None
# Include stats
if include_stats and (focus_areas is None or "stats" in focus_areas):
try:
summary = client.get_user_summary(today)
result["today_stats"] = {
"totalSteps": summary.get("totalSteps"),
"totalDistanceMeters": summary.get("totalDistanceMeters"),
"totalKilocalories": summary.get("totalKilocalories"),
"floorsClimbed": summary.get("floorsClimbed"),
"activeTimeInSeconds": summary.get("activeTimeInSeconds"),
"minHeartRate": summary.get("minHeartRate"),
"maxHeartRate": summary.get("maxHeartRate"),
"restingHeartRate": summary.get("restingHeartRate"),
}
except Exception as e:
_log_error("Failed to fetch stats", e)
result["today_stats"] = None
# Include health metrics
if include_health and (focus_areas is None or "health" in focus_areas):
health = {}
try:
hr = client.get_heart_rates(today)
health["heart_rate"] = {
"resting": hr.get("restingHeartRate"),
"min": hr.get("minHeartRate"),
"max": hr.get("maxHeartRate"),
}
except Exception as e:
_log_error("Failed to fetch heart rate", e)
health["heart_rate"] = None
try:
sleep = client.get_sleep_data(today)
if sleep and "dailySleepDTO" in sleep:
dto = sleep["dailySleepDTO"]
health["sleep"] = {
"sleepTimeSeconds": dto.get("sleepTimeSeconds"),
"deepSleepSeconds": dto.get("deepSleepSeconds"),
"lightSleepSeconds": dto.get("lightSleepSeconds"),
"remSleepSeconds": dto.get("remSleepSeconds"),
"awakeSleepSeconds": dto.get("awakeSleepSeconds"),
}
else:
health["sleep"] = None
except Exception as e:
_log_error("Failed to fetch sleep data", e)
health["sleep"] = None
try:
bb = client.get_body_battery(today)
if bb and isinstance(bb, list) and len(bb) > 0:
# Get latest body battery reading
health["body_battery"] = bb[-1] if bb else None
else:
health["body_battery"] = None
except Exception as e:
_log_error("Failed to fetch body battery", e)
health["body_battery"] = None
try:
stress = client.get_stress_data(today)
if stress:
health["stress"] = {
"overallStressLevel": stress.get("overallStressLevel"),
"restStressLevel": stress.get("restStressLevel"),
"activityStressLevel": stress.get("activityStressLevel"),
}
else:
health["stress"] = None
except Exception as e:
_log_error("Failed to fetch stress data", e)
health["stress"] = None
result["health"] = health
# Include training metrics
if include_training and (focus_areas is None or "training" in focus_areas):
training = {}
try:
status = client.get_training_status(today)
training["status"] = status.get("trainingStatusPhrase")
except Exception as e:
_log_error("Failed to fetch training status", e)
training["status"] = None
try:
readiness = client.get_training_readiness(today)
training["readiness"] = readiness.get("readinessScore")
except Exception as e:
_log_error("Failed to fetch training readiness", e)
training["readiness"] = None
try:
metrics = client.get_max_metrics(today)
if metrics:
generic = metrics.get("generic", {})
cycling = metrics.get("cycling", {})
training["vo2max_running"] = generic.get("vo2MaxValue")
training["vo2max_cycling"] = cycling.get("vo2MaxValue")
except Exception as e:
_log_error("Failed to fetch VO2max metrics", e)
training["vo2max_running"] = None
training["vo2max_cycling"] = None
result["training"] = training
# Include weight and body composition
if include_weight and (focus_areas is None or "weight" in focus_areas):
weight_data = {}
try:
body_comp = client.get_body_composition(today)
if body_comp:
# Weight is in grams, convert to kg
weight_g = body_comp.get("weight")
weight_data["current_kg"] = weight_g / 1000 if weight_g else None
weight_data["body_fat_pct"] = body_comp.get("bodyFat")
# Muscle mass is in grams, convert to kg
muscle_g = body_comp.get("muscleMass")
weight_data["muscle_mass_kg"] = muscle_g / 1000 if muscle_g else None
except Exception as e:
_log_error("Failed to fetch body composition", e)
weight_data["current_kg"] = None
weight_data["body_fat_pct"] = None
weight_data["muscle_mass_kg"] = None
result["weight"] = weight_data
# Include recent activities
if focus_areas is None or "activities" in focus_areas:
try:
activities = client.get_activities(start=0, limit=activities_limit)
result["recent_activities"] = [
{
"activityId": a.get("activityId"),
"activityName": a.get("activityName"),
"activityType": a.get("activityType", {}).get("typeKey")
if isinstance(a.get("activityType"), dict)
else a.get("activityType"),
"distance": a.get("distance"),
"duration": a.get("duration"),
"startTimeLocal": a.get("startTimeLocal"),
"averageHR": a.get("averageHR"),
"calories": a.get("calories"),
"elevationGain": a.get("elevationGain"),
}
for a in activities
]
except Exception as e:
_log_error("Failed to fetch activities", e)
result["recent_activities"] = []
emit(result)
"""Health data commands."""
from __future__ import annotations
from datetime import date
from typing import Annotated
import typer
from garmin_connect_cli.client import GarminClient
from garmin_connect_cli.core import emit, with_client
app = typer.Typer(no_args_is_help=True)
@app.command("sleep")
@with_client
def sleep(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for sleep data (YYYY-MM-DD, default: today)"),
] = None,
) -> None:
"""Get sleep data.
Returns sleep stages, duration, and quality metrics.
Examples:
garmin-connect health sleep
garmin-connect health sleep --date 2025-01-01
garmin-connect health sleep | jq '.dailySleepDTO.sleepTimeSeconds'
"""
target_date = date_str or date.today().isoformat()
sleep_data = client.get_sleep_data(target_date)
emit(sleep_data)
@app.command("heart-rate")
@with_client
def heart_rate(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for heart rate data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get heart rate data.
Returns resting, min, max heart rates and timestamped values.
Examples:
garmin-connect health heart-rate
garmin-connect health heart-rate | jq '.restingHeartRate'
"""
target_date = date_str or date.today().isoformat()
hr_data = client.get_heart_rates(target_date)
emit(hr_data)
@app.command("steps")
@with_client
def steps(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for steps data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get steps data.
Examples:
garmin-connect health steps
garmin-connect health steps --date 2025-01-01
"""
target_date = date_str or date.today().isoformat()
steps_data = client.get_steps_data(target_date)
emit(steps_data)
@app.command("stress")
@with_client
def stress(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for stress data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get stress data.
Examples:
garmin-connect health stress
garmin-connect health stress | jq '.overallStressLevel'
"""
target_date = date_str or date.today().isoformat()
stress_data = client.get_stress_data(target_date)
emit(stress_data)
@app.command("body-battery")
@with_client
def body_battery(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for body battery data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get body battery data.
Examples:
garmin-connect health body-battery
garmin-connect health body-battery | jq '.[0].bodyBatteryLevel'
"""
target_date = date_str or date.today().isoformat()
bb_data = client.get_body_battery(target_date)
emit(bb_data)
@app.command("rhr")
@with_client
def resting_heart_rate(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for RHR (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get resting heart rate.
Examples:
garmin-connect health rhr
"""
target_date = date_str or date.today().isoformat()
rhr_data = client.get_rhr_day(target_date)
emit(rhr_data)
"""Training metrics commands."""
from __future__ import annotations
from datetime import date
from typing import Annotated
import typer
from garmin_connect_cli.client import GarminClient
from garmin_connect_cli.core import emit, with_client
app = typer.Typer(no_args_is_help=True)
@app.command("status")
@with_client
def training_status(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for training status (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get training status.
Returns training status (Productive, Peaking, Recovery, Unproductive, etc.).
Examples:
garmin-connect training status
garmin-connect training status --date 2025-01-01
"""
target_date = date_str or date.today().isoformat()
data = client.get_training_status(target_date)
emit(data)
@app.command("readiness")
@with_client
def training_readiness(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for training readiness (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get training readiness score.
Returns training readiness score (0-100) indicating recovery state.
Examples:
garmin-connect training readiness
garmin-connect training readiness | jq '.readinessScore'
"""
target_date = date_str or date.today().isoformat()
data = client.get_training_readiness(target_date)
emit(data)
@app.command("vo2max")
@with_client
def vo2max(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for VO2 max data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get VO2 max estimates.
Returns VO2 max estimates for running and cycling.
Examples:
garmin-connect training vo2max
garmin-connect training vo2max | jq '.generic.vo2MaxValue'
"""
target_date = date_str or date.today().isoformat()
data = client.get_max_metrics(target_date)
emit(data)
@app.command("lactate")
@with_client
def lactate_threshold(client: GarminClient) -> None:
"""Get lactate threshold data.
Returns lactate threshold heart rate, pace, and power (if available).
Examples:
garmin-connect training lactate
garmin-connect training lactate | jq '.lactateThresholdHeartRateInBeatsPerMinute'
"""
data = client.get_lactate_threshold()
emit(data)
@app.command("endurance")
@with_client
def endurance_score(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for endurance score (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get endurance score.
Returns endurance score data.
Examples:
garmin-connect training endurance
garmin-connect training endurance --date 2025-01-01
"""
target_date = date_str or date.today().isoformat()
data = client.get_endurance_score(target_date)
emit(data)
@app.command("hill")
@with_client
def hill_score(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for hill score (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get hill score.
Returns hill/climbing ability score.
Examples:
garmin-connect training hill
garmin-connect training hill --date 2025-01-01
"""
target_date = date_str or date.today().isoformat()
data = client.get_hill_score(target_date)
emit(data)
@app.command("hrv")
@with_client
def hrv_data(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for HRV data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get heart rate variability data.
Returns HRV metrics including RMSSD and status.
Examples:
garmin-connect training hrv
garmin-connect training hrv | jq '.hrvSummary'
"""
target_date = date_str or date.today().isoformat()
data = client.get_hrv_data(target_date)
emit(data)
@app.command("fitness-age")
@with_client
def fitness_age(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for fitness age data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get fitness age.
Returns calculated fitness age based on VO2 max and activity.
Examples:
garmin-connect training fitness-age
garmin-connect training fitness-age --date 2025-01-01
garmin-connect training fitness-age | jq '.fitnessAge'
"""
target_date = date_str or date.today().isoformat()
data = client.get_fitnessage_data(target_date)
emit(data)
"""Weight and body composition commands."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Annotated
import typer
from garmin_connect_cli.client import GarminClient
from garmin_connect_cli.core import emit, emit_result, with_client
app = typer.Typer(no_args_is_help=True)
@app.command("list")
@with_client
def list_weights(
client: GarminClient,
start: Annotated[
str | None,
typer.Option("--start", "-s", help="Start date (YYYY-MM-DD, default: 30 days ago)"),
] = None,
end: Annotated[
str | None,
typer.Option("--end", "-e", help="End date (YYYY-MM-DD, default: today)"),
] = None,
) -> None:
"""List weight entries.
Returns weight measurements between start and end dates.
Examples:
garmin-connect weight list
garmin-connect weight list --start 2025-01-01 --end 2025-01-31
garmin-connect weight list | jq '.[].weight'
"""
end_date = end or date.today().isoformat()
start_date = start or (date.today() - timedelta(days=30)).isoformat()
data = client.get_weigh_ins(start_date, end_date)
emit(data)
@app.command("get")
@with_client
def get_weight(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for weight data (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get weight for a specific date.
Returns weight measurement for the specified date.
Examples:
garmin-connect weight get
garmin-connect weight get --date 2025-01-01
"""
target_date = date_str or date.today().isoformat()
data = client.get_daily_weigh_ins(target_date)
emit(data)
@app.command("body-comp")
@with_client
def body_composition(
client: GarminClient,
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for body composition (YYYY-MM-DD)"),
] = None,
) -> None:
"""Get body composition data.
Returns body fat percentage, muscle mass, bone mass, water percentage.
Examples:
garmin-connect weight body-comp
garmin-connect weight body-comp | jq '.bodyFat'
"""
target_date = date_str or date.today().isoformat()
data = client.get_body_composition(target_date)
emit(data)
@app.command("log")
@with_client
def log_weight(
client: GarminClient,
weight: Annotated[float, typer.Argument(help="Weight in kilograms")],
date_str: Annotated[
str | None,
typer.Option("--date", "-d", help="Date for weight entry (YYYY-MM-DD, default: today)"),
] = None,
) -> None:
"""Log a weight measurement.
Records a weight measurement to Garmin Connect.
Examples:
garmin-connect weight log 70.5
garmin-connect weight log 70.5 --date 2025-01-01
"""
target_date = date_str or date.today().isoformat()
result = client.add_weigh_in(weight=weight, unitKey="kg", date=target_date)
data = result if result else {"weight": weight, "date": target_date}
emit_result(data, f"Weight {weight} kg logged for {target_date}")
@app.command("delete")
@with_client
def delete_weight(
client: GarminClient,
pk: Annotated[int, typer.Argument(help="Weight entry primary key to delete")],
date_str: Annotated[
str,
typer.Option("--date", "-d", help="Date of the weight entry (YYYY-MM-DD)"),
],
force: Annotated[
bool,
typer.Option("--force", "-f", help="Skip confirmation prompt"),
] = False,
) -> None:
"""Delete a weight entry.
Deletes a specific weight entry by its primary key and date.
Examples:
garmin-connect weight delete 12345678 --date 2025-01-01
garmin-connect weight delete 12345678 --date 2025-01-01 --force
"""
if not force:
confirm = typer.confirm(f"Delete weight entry {pk} for {date_str}?")
if not confirm:
raise typer.Abort()
client.delete_weigh_in(pk, date_str)
emit_result({"pk": pk, "date": date_str}, f"Weight entry {pk} deleted for {date_str}")
@app.command("delete-date")
@with_client
def delete_weights_for_date(
client: GarminClient,
date_str: Annotated[str, typer.Argument(help="Date to delete weights for (YYYY-MM-DD)")],
force: Annotated[
bool,
typer.Option("--force", "-f", help="Skip confirmation prompt"),
] = False,
) -> None:
"""Delete all weight entries for a date.
Deletes all weight measurements recorded on the specified date.
Examples:
garmin-connect weight delete-date 2025-01-01
garmin-connect weight delete-date 2025-01-01 --force
"""
if not force:
confirm = typer.confirm(f"Delete all weight entries for {date_str}?")
if not confirm:
raise typer.Abort()
client.delete_weigh_ins(date_str)
emit_result({"date": date_str}, f"Weight entries for {date_str} deleted")
"""Tests for garmin-connect-cli."""