health-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/SKILL.md +150 -0
- package/dist/index.js +375 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Linh Phung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# health-agent
|
|
2
|
+
|
|
3
|
+
**Read your own Fitbit / Pixel health data from the command line — and let an AI agent drive it.**
|
|
4
|
+
|
|
5
|
+
A small CLI + Claude Code skill over the [Google Health API](https://developers.google.com/health) (the successor to the Fitbit Web API, which sunsets September 2026). Authenticates with Google OAuth 2.0 and pulls steps, sleep, exercise, body metrics, and profile data as JSON.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g health-agent # installs the `health-agent` command
|
|
11
|
+
# or
|
|
12
|
+
pnpm install -g health-agent
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
As a Claude Code plugin (bundles the skill so Claude knows how to drive the CLI):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
/plugin marketplace add codevagabond/health-agent
|
|
19
|
+
/plugin install health-agent@health-agent
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Local dev:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd ~/code/health-agent && npm install && npm run build && npm link
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## One-time setup
|
|
29
|
+
|
|
30
|
+
The login is human-in-the-loop by design — no agent can (or should) hold your Google login. ~10 minutes, once. **Tip:** ask Claude Code "set up health-agent" and it'll walk you through this step by step (see `SKILL.md`).
|
|
31
|
+
|
|
32
|
+
1. **Create a project** → https://console.cloud.google.com/projectcreate — name it, and keep it selected in the top bar for the rest.
|
|
33
|
+
2. **Enable the API** → https://console.cloud.google.com/apis/library/health.googleapis.com → **Enable**.
|
|
34
|
+
3. **Consent screen** → https://console.cloud.google.com/auth/audience
|
|
35
|
+
- If it says *"Google Auth Platform not configured yet"*, click **Get started** and finish the short wizard (app name → support email → Audience: **External** → contact email → Create).
|
|
36
|
+
- Keep **Publishing status = Testing** (skips the heavyweight restricted-scope verification). Trade-off: refresh tokens expire after 7 days — just `auth:login` again.
|
|
37
|
+
- **Test users → + Add users** → add the **exact Google account your Fitbit/Pixel syncs to** (and that you'll log in with). Save.
|
|
38
|
+
4. **OAuth client** → https://console.cloud.google.com/auth/clients → **+ Create client** → Application type: **Desktop app** → Create. Copy the **Client ID** + **Secret**.
|
|
39
|
+
- ⚠️ Must be **Desktop app**, not "Web application" — the CLI uses a `127.0.0.1` loopback redirect, and a Web client gives `redirect_uri_mismatch`.
|
|
40
|
+
5. **Configure + log in**:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
health-agent auth:setup --client-id <ID> --client-secret <SECRET>
|
|
44
|
+
health-agent auth:login # opens browser → Advanced → Go to … (unsafe) → Allow
|
|
45
|
+
health-agent auth:status
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Credentials live in `~/.health-agent/` (mode 600). Tokens refresh automatically.
|
|
49
|
+
|
|
50
|
+
**Gotcha:** *"Access blocked: … has not completed the Google verification process"* means the account you signed in with isn't on the **Test users** list (step 3) — add the exact account, save, retry. The softer *"Google hasn't verified this app"* warning is expected: click **Advanced → Go to … (unsafe) → Allow**.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
health-agent steps # reconciled Fitbit/Pixel steps
|
|
56
|
+
health-agent sleep
|
|
57
|
+
health-agent exercise
|
|
58
|
+
health-agent get body-fat --reconcile
|
|
59
|
+
health-agent get sleep -w \
|
|
60
|
+
--filter 'sleep.interval.civil_end_time >= "2026-06-01"'
|
|
61
|
+
health-agent profile
|
|
62
|
+
health-agent raw 'users/me/dataTypes/steps/dataPoints' # escape hatch
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
JSON goes to stdout, diagnostics to stderr — pipe to `jq` freely.
|
|
66
|
+
|
|
67
|
+
## Data model notes
|
|
68
|
+
|
|
69
|
+
- **Reconciled stream** (`--reconcile` / `-w`): deduped data matching what the Fitbit app shows. The plain list returns raw per-source data points.
|
|
70
|
+
- **Third-party logs (Hevy, Strava, …)** arrive via **Health Connect** (`dataSource.platform == "HEALTH_CONNECT"`) and are **excluded from the reconciled/wearables stream**. For workouts, use the plain `health-agent get exercise` (not `health-agent exercise`). Hevy stores the full set/rep/weight log as text in `.exercise.notes`.
|
|
71
|
+
- Data type ids in the **path** are kebab-case (`body-fat`); in **filter** expressions they're snake_case (`body_fat`).
|
|
72
|
+
- Source family for Fitbit/Pixel devices: `users/me/dataSourceFamilies/google-wearables`.
|
|
73
|
+
- Full data type + scope reference: https://developers.google.com/health/scopes
|
|
74
|
+
|
|
75
|
+
## Scopes requested by default (read-only)
|
|
76
|
+
|
|
77
|
+
`activity_and_fitness`, `sleep`, `health_metrics_and_measurements`, `profile`.
|
|
78
|
+
Override with `auth:setup --scopes "<space-separated full scope URLs>"`.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: health-agent
|
|
3
|
+
description: Pull your own Fitbit / Pixel health data (steps, sleep, exercise, body metrics, profile) from the Google Health API via the `health-agent` CLI. Use when the user wants to read, export, summarize, or analyze their Fitbit/Google health data.
|
|
4
|
+
homepage: https://developers.google.com/health
|
|
5
|
+
metadata: {"openclaw":{"emoji":"❤️","requires":{"bins":["health-agent"],"env":[]}}}
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# health-agent
|
|
9
|
+
|
|
10
|
+
CLI wrapper around the **Google Health API** (the successor to the Fitbit Web API; legacy API sunsets September 2026). Authenticates with Google OAuth 2.0 and reads the caller's own health data.
|
|
11
|
+
|
|
12
|
+
| Property | Value |
|
|
13
|
+
|----------|-------|
|
|
14
|
+
| **name** | health-agent |
|
|
15
|
+
| **allowed-tools** | Bash(health-agent:*) |
|
|
16
|
+
| **API base** | `https://health.googleapis.com/v4` |
|
|
17
|
+
|
|
18
|
+
## ⚠️ Hard rules
|
|
19
|
+
|
|
20
|
+
1. **Auth is required.** Every data command fails without valid credentials. Check with `health-agent auth:status` first.
|
|
21
|
+
2. **This reads the authenticated user's OWN data only.** There is no way to pull someone else's data without their separate OAuth consent.
|
|
22
|
+
3. **Output is JSON on stdout**, diagnostics on stderr — safe to pipe to `jq`.
|
|
23
|
+
|
|
24
|
+
## First-time setup — agent onboarding playbook (human-in-the-loop)
|
|
25
|
+
|
|
26
|
+
If `health-agent auth:status` shows `"configured": false` (or the command isn't on PATH yet), the user has never set this up. **Walk them through the steps below ONE AT A TIME** — give the exact URL for the current step, tell them what to click, then *wait for them to confirm before moving to the next step*. Do NOT paste all steps at once; this flow has subtle gotchas and users get lost in a wall of links. An agent **cannot** do the Google login or the consent click — those are the human's job. Everything else (install, `auth:setup`, reading data) the agent does.
|
|
27
|
+
|
|
28
|
+
Why this is more involved than a typical CLI login: there is no public Google Health auth server, so the user must own their own Google Cloud OAuth client. This is a ~10-minute one-time setup.
|
|
29
|
+
|
|
30
|
+
### Step 0 — Install the CLI if it doesn't exist (agent does this)
|
|
31
|
+
If the `health-agent` command isn't on PATH, install it globally:
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g health-agent
|
|
34
|
+
# or
|
|
35
|
+
pnpm install -g health-agent
|
|
36
|
+
```
|
|
37
|
+
npm package: https://www.npmjs.com/package/health-agent
|
|
38
|
+
|
|
39
|
+
### Step 1 — Create / pick a Google Cloud project (user)
|
|
40
|
+
→ https://console.cloud.google.com/projectcreate
|
|
41
|
+
Name it e.g. `health-agent`, click **Create**, and make sure it's the **selected project** in the top bar for every step after this.
|
|
42
|
+
|
|
43
|
+
### Step 2 — Enable the Google Health API (user)
|
|
44
|
+
→ https://console.cloud.google.com/apis/library/health.googleapis.com
|
|
45
|
+
Click **Enable**.
|
|
46
|
+
|
|
47
|
+
### Step 3 — Configure the consent screen / Google Auth Platform (user)
|
|
48
|
+
→ https://console.cloud.google.com/auth/audience
|
|
49
|
+
- **NEW console gotcha:** if it says *"Google Auth Platform not configured yet"*, click **Get started** and complete the short wizard first: App name (anything) → User support email → Audience: **External** → Contact email → agree → **Create**.
|
|
50
|
+
- Confirm **Publishing status = Testing** (do NOT publish — Testing mode skips the heavyweight restricted-scope verification review).
|
|
51
|
+
- Under **Test users** → **+ Add users** → add the user's Google account → **Save**.
|
|
52
|
+
- ⚠️ **The test-user account MUST be the exact same Google account whose Fitbit/Pixel data syncs**, and the same account they will sign in with at login. Ask the user which account their Fitbit/Pixel is tied to and confirm it matches.
|
|
53
|
+
|
|
54
|
+
### Step 4 — Create the OAuth client (user)
|
|
55
|
+
→ https://console.cloud.google.com/auth/clients (in the new console, OAuth clients live here, NOT on the old `/apis/credentials` page)
|
|
56
|
+
**+ Create client** → Application type: **Desktop app** → **Create**.
|
|
57
|
+
- ⚠️ **It MUST be "Desktop app", NOT "Web application".** The CLI uses a `127.0.0.1` loopback redirect; a Web client without loopback redirects causes `redirect_uri_mismatch`. (Ignore any generic Google guide that says pick "Web Server".)
|
|
58
|
+
- Copy the **Client ID** and **Client Secret** (or download the JSON).
|
|
59
|
+
|
|
60
|
+
### Step 5 — Configure + log in (agent runs setup; user clicks Allow)
|
|
61
|
+
```bash
|
|
62
|
+
health-agent auth:setup --client-id <ID> --client-secret <SECRET>
|
|
63
|
+
health-agent auth:login # opens the browser
|
|
64
|
+
health-agent auth:status # verify "loggedIn": true
|
|
65
|
+
```
|
|
66
|
+
At the browser screen, tell the user to:
|
|
67
|
+
1. Pick the **test-user account** from Step 3.
|
|
68
|
+
2. On the **"Google hasn't verified this app"** warning (normal in Testing mode) → **Advanced** → **Go to health-agent (unsafe)**.
|
|
69
|
+
3. Tick the scope boxes → **Allow**.
|
|
70
|
+
|
|
71
|
+
`auth:login` runs a local loopback server and blocks until the user clicks Allow — run it in the background and watch its output for the `✅ Logged in` line, or have the user run it with a `! ` prefix.
|
|
72
|
+
|
|
73
|
+
`auth:setup` writes `~/.health-agent/config.json`; `auth:login` writes `~/.health-agent/credentials.json`. Tokens auto-refresh. `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` env vars override stored config for headless use.
|
|
74
|
+
|
|
75
|
+
### Setup troubleshooting (decision tree)
|
|
76
|
+
- **"Access blocked: health-agent has not completed the Google verification process"** (hard block, no "Advanced" option) → the signing-in account is **not on the Test users list**, or publishing status isn't "Testing". Fix Step 3: add the exact account, Save, wait ~1 min, retry. Most common cause: signing in with a different account than the one added.
|
|
77
|
+
- **"Google hasn't verified this app"** (soft warning with an Advanced link) → expected in Testing mode; click **Advanced → Go to … (unsafe) → Allow**.
|
|
78
|
+
- **`redirect_uri_mismatch`** → the OAuth client is a "Web" type. Recreate it as **Desktop app** (Step 4).
|
|
79
|
+
- **`invalid_grant` / refresh failed later on** → Testing-mode refresh tokens expire after **7 days**. Just re-run `health-agent auth:login`.
|
|
80
|
+
- **`403` / scope error on a data command** → that data type's scope wasn't granted at consent. Re-run `auth:setup` with the right `--scopes`, then `auth:login`.
|
|
81
|
+
|
|
82
|
+
## Reading data
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# Convenience commands — default to the reconciled Fitbit/Pixel stream:
|
|
86
|
+
health-agent steps
|
|
87
|
+
health-agent sleep
|
|
88
|
+
health-agent exercise
|
|
89
|
+
|
|
90
|
+
# Any data type (kebab-case id): steps, sleep, exercise, body-fat, ...
|
|
91
|
+
health-agent get steps --wearables --limit 50
|
|
92
|
+
health-agent get body-fat --reconcile
|
|
93
|
+
|
|
94
|
+
# Date filtering uses a Google Health filter string (field names are snake_case per data type):
|
|
95
|
+
health-agent get sleep --wearables \
|
|
96
|
+
--filter 'sleep.interval.civil_end_time >= "2026-06-01"'
|
|
97
|
+
|
|
98
|
+
# Profile and escape hatch for any v4 path:
|
|
99
|
+
health-agent profile
|
|
100
|
+
health-agent raw 'users/me/dataTypes/steps/dataPoints:dailyRollUp'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Flags (data commands)
|
|
104
|
+
- `--reconcile` — deduped stream (what the Fitbit app shows)
|
|
105
|
+
- `--wearables` / `-w` — reconcile against `google-wearables` (Fitbit/Pixel) source
|
|
106
|
+
- `--source <family>` — restrict to a data source family (implies reconcile)
|
|
107
|
+
- `--filter <expr>` / `-f` — Google Health API filter expression
|
|
108
|
+
- `--limit <n>` / `-n` — page size; `--page-token` — pagination
|
|
109
|
+
|
|
110
|
+
## ⚠️ Third-party / Health Connect data (Hevy, Strava, etc.) — READ THIS for any workout question
|
|
111
|
+
|
|
112
|
+
Data from third-party apps (e.g. **Hevy** strength logs, Strava, MyFitnessPal) reaches the Google Health API through **Health Connect**, NOT through the Fitbit cloud. These records have `dataSource.platform == "HEALTH_CONNECT"` and `dataSource.application.packageName` (e.g. `com.hevy`).
|
|
113
|
+
|
|
114
|
+
**The reconciled/wearables stream EXCLUDES them.** `--reconcile`, `--wearables`/`-w`, and the convenience commands (`steps`/`sleep`/`exercise`) reconcile against the `google-wearables` (Fitbit/Pixel) source only, so Health Connect sessions are silently dropped.
|
|
115
|
+
|
|
116
|
+
**Rule: when the user asks about workouts/training/exercise, ALWAYS query the plain (unreconciled) stream so third-party logs are included:**
|
|
117
|
+
```bash
|
|
118
|
+
health-agent get exercise -n 30 # NOT `health-agent exercise` (that one is wearables-only)
|
|
119
|
+
```
|
|
120
|
+
Then split by source to see everything:
|
|
121
|
+
```bash
|
|
122
|
+
# Third-party sessions (Hevy, Strava, …):
|
|
123
|
+
health-agent get exercise -n 30 | jq '.dataPoints[] | select(.dataSource.platform=="HEALTH_CONNECT")'
|
|
124
|
+
# Fitbit/Pixel auto-detected sessions:
|
|
125
|
+
health-agent get exercise -n 30 | jq '.dataPoints[] | select(.dataSource.platform=="FITBIT")'
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**The full set/rep/weight log is in `.exercise.notes`.** Hevy writes the entire workout (every exercise, set, kg × reps, plus a `hevy.com/workout/<id>` link) as plain text into the `notes` field. `metricsSummary`/`exerciseMetadata` are usually `{}` for these — don't conclude "no detail," read `notes`:
|
|
129
|
+
```bash
|
|
130
|
+
health-agent get exercise -n 30 | jq -r '.dataPoints[]
|
|
131
|
+
| select(.dataSource.application.packageName=="com.hevy")
|
|
132
|
+
| "\(.exercise.interval.startTime) (\((.exercise.activeDuration|sub("s";"")|tonumber/60|floor)) min)\n\(.exercise.notes)\n"'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A single workout often appears **twice**: once as the third-party log (e.g. Hevy `STRENGTH_TRAINING`, rich `notes`, no biometrics) and once as the Fitbit band's overlapping auto-detected session (e.g. `CARDIO_WORKOUT`, has HR/zones, no set log). Cross-reference by overlapping time window to combine the set log with the heart-rate signature.
|
|
136
|
+
|
|
137
|
+
## Install
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
cd ~/code/health-agent && npm install && npm run build && npm link
|
|
141
|
+
# or run without linking: node ~/code/health-agent/dist/index.js <command>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Troubleshooting
|
|
145
|
+
For auth / setup errors (`invalid_grant`, `403` scope, `redirect_uri_mismatch`, "Access blocked", "Google hasn't verified this app") see the **Setup troubleshooting (decision tree)** at the end of the First-time setup playbook above.
|
|
146
|
+
|
|
147
|
+
Data-command notes:
|
|
148
|
+
- **Empty `dataPoints`** → no data in range, or the device hasn't synced. Widen the `--filter` window or confirm the Fitbit/Pixel app has synced recently.
|
|
149
|
+
- **Exercise field names** → each session's activity is under `.exercise.exerciseType` (enum, e.g. `WALKING`) with a friendly `.exercise.displayName` (e.g. `Walk`); rich metrics live under `.exercise.metricsSummary` (calories, distance, steps, avg HR, active-zone minutes). There is no `activityType` field.
|
|
150
|
+
- **`profile`** returns `age`, `membershipStartDate`, and configured walking/running stride lengths from `users/me/profile`.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import yargs from "yargs";
|
|
5
|
+
import { hideBin } from "yargs/helpers";
|
|
6
|
+
|
|
7
|
+
// src/auth.ts
|
|
8
|
+
import http from "http";
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
import { spawn } from "child_process";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import { URL } from "url";
|
|
15
|
+
var CONFIG_DIR = path.join(os.homedir(), ".health-agent");
|
|
16
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
17
|
+
var CREDS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
18
|
+
var AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
19
|
+
var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
20
|
+
var DEFAULT_SCOPES = [
|
|
21
|
+
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
|
|
22
|
+
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
|
|
23
|
+
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
|
|
24
|
+
"https://www.googleapis.com/auth/googlehealth.profile.readonly"
|
|
25
|
+
];
|
|
26
|
+
function ensureDir() {
|
|
27
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
28
|
+
}
|
|
29
|
+
function saveConfig(cfg) {
|
|
30
|
+
ensureDir();
|
|
31
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
32
|
+
}
|
|
33
|
+
function loadConfig() {
|
|
34
|
+
const envId = process.env.GOOGLE_CLIENT_ID;
|
|
35
|
+
const envSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
36
|
+
if (envId && envSecret) {
|
|
37
|
+
return {
|
|
38
|
+
clientId: envId,
|
|
39
|
+
clientSecret: envSecret,
|
|
40
|
+
scopes: process.env.HEALTH_SCOPES?.split(/[ ,]+/).filter(Boolean) ?? DEFAULT_SCOPES
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
44
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
function saveCreds(creds) {
|
|
47
|
+
ensureDir();
|
|
48
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
49
|
+
}
|
|
50
|
+
function loadCreds() {
|
|
51
|
+
if (!fs.existsSync(CREDS_FILE)) return null;
|
|
52
|
+
return JSON.parse(fs.readFileSync(CREDS_FILE, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
function clearCreds() {
|
|
55
|
+
if (fs.existsSync(CREDS_FILE)) fs.rmSync(CREDS_FILE);
|
|
56
|
+
}
|
|
57
|
+
function base64url(buf) {
|
|
58
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
59
|
+
}
|
|
60
|
+
function openBrowser(url) {
|
|
61
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
62
|
+
const args = process.platform === "win32" ? ["", url] : [url];
|
|
63
|
+
spawn(cmd, args, { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
|
|
64
|
+
}
|
|
65
|
+
async function login() {
|
|
66
|
+
const config = loadConfig();
|
|
67
|
+
if (!config) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
"No OAuth client configured. Run `health-agent auth:setup --client-id <id> --client-secret <secret>` first."
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const verifier = base64url(crypto.randomBytes(32));
|
|
73
|
+
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
|
|
74
|
+
const state = base64url(crypto.randomBytes(16));
|
|
75
|
+
const { code, redirectUri } = await new Promise(
|
|
76
|
+
(resolve, reject) => {
|
|
77
|
+
const server = http.createServer((req, res) => {
|
|
78
|
+
try {
|
|
79
|
+
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
80
|
+
if (reqUrl.pathname !== "/callback") {
|
|
81
|
+
res.writeHead(404).end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const returnedState = reqUrl.searchParams.get("state");
|
|
85
|
+
const err = reqUrl.searchParams.get("error");
|
|
86
|
+
const gotCode = reqUrl.searchParams.get("code");
|
|
87
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
88
|
+
if (err || !gotCode || returnedState !== state) {
|
|
89
|
+
res.end("<h2>Authorization failed.</h2><p>You can close this tab and check the terminal.</p>");
|
|
90
|
+
server.close();
|
|
91
|
+
reject(new Error(err ?? (returnedState !== state ? "state mismatch" : "no code returned")));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.end("<h2>\u2705 Connected.</h2><p>You can close this tab and return to the terminal.</p>");
|
|
95
|
+
server.close();
|
|
96
|
+
resolve({ code: gotCode, redirectUri: boundRedirectUri });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
reject(e);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
let boundRedirectUri = "";
|
|
102
|
+
server.listen(0, "127.0.0.1", () => {
|
|
103
|
+
const addr = server.address();
|
|
104
|
+
if (!addr || typeof addr === "string") {
|
|
105
|
+
reject(new Error("failed to bind local callback server"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
boundRedirectUri = `http://127.0.0.1:${addr.port}/callback`;
|
|
109
|
+
const authUrl = new URL(AUTH_ENDPOINT);
|
|
110
|
+
authUrl.searchParams.set("client_id", config.clientId);
|
|
111
|
+
authUrl.searchParams.set("redirect_uri", boundRedirectUri);
|
|
112
|
+
authUrl.searchParams.set("response_type", "code");
|
|
113
|
+
authUrl.searchParams.set("scope", config.scopes.join(" "));
|
|
114
|
+
authUrl.searchParams.set("access_type", "offline");
|
|
115
|
+
authUrl.searchParams.set("prompt", "consent");
|
|
116
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
117
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
118
|
+
authUrl.searchParams.set("state", state);
|
|
119
|
+
console.error("\nOpening your browser to authorize the Google Health API\u2026");
|
|
120
|
+
console.error("If it does not open, paste this URL manually:\n");
|
|
121
|
+
console.error(authUrl.toString() + "\n");
|
|
122
|
+
openBrowser(authUrl.toString());
|
|
123
|
+
});
|
|
124
|
+
server.on("error", reject);
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
return exchangeCode(config, code, verifier, redirectUri);
|
|
128
|
+
}
|
|
129
|
+
async function exchangeCode(config, code, verifier, redirectUri) {
|
|
130
|
+
const body = new URLSearchParams({
|
|
131
|
+
code,
|
|
132
|
+
client_id: config.clientId,
|
|
133
|
+
client_secret: config.clientSecret,
|
|
134
|
+
redirect_uri: redirectUri,
|
|
135
|
+
grant_type: "authorization_code",
|
|
136
|
+
code_verifier: verifier
|
|
137
|
+
});
|
|
138
|
+
const resp = await fetch(TOKEN_ENDPOINT, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
141
|
+
body
|
|
142
|
+
});
|
|
143
|
+
if (!resp.ok) {
|
|
144
|
+
throw new Error(`Token exchange failed (${resp.status}): ${await resp.text()}`);
|
|
145
|
+
}
|
|
146
|
+
const json = await resp.json();
|
|
147
|
+
const creds = {
|
|
148
|
+
accessToken: json.access_token,
|
|
149
|
+
refreshToken: json.refresh_token,
|
|
150
|
+
expiresAt: Date.now() + (json.expires_in ?? 3600) * 1e3,
|
|
151
|
+
scope: json.scope ?? config.scopes.join(" "),
|
|
152
|
+
tokenType: json.token_type ?? "Bearer"
|
|
153
|
+
};
|
|
154
|
+
saveCreds(creds);
|
|
155
|
+
return creds;
|
|
156
|
+
}
|
|
157
|
+
async function refresh(config, creds) {
|
|
158
|
+
if (!creds.refreshToken) {
|
|
159
|
+
throw new Error("Access token expired and no refresh token available. Run `health-agent auth:login` again.");
|
|
160
|
+
}
|
|
161
|
+
const body = new URLSearchParams({
|
|
162
|
+
client_id: config.clientId,
|
|
163
|
+
client_secret: config.clientSecret,
|
|
164
|
+
refresh_token: creds.refreshToken,
|
|
165
|
+
grant_type: "refresh_token"
|
|
166
|
+
});
|
|
167
|
+
const resp = await fetch(TOKEN_ENDPOINT, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
170
|
+
body
|
|
171
|
+
});
|
|
172
|
+
if (!resp.ok) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Token refresh failed (${resp.status}): ${await resp.text()}
|
|
175
|
+
In OAuth "Testing" mode refresh tokens expire after 7 days \u2014 just run \`health-agent auth:login\` again.`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const json = await resp.json();
|
|
179
|
+
const updated = {
|
|
180
|
+
...creds,
|
|
181
|
+
accessToken: json.access_token,
|
|
182
|
+
expiresAt: Date.now() + (json.expires_in ?? 3600) * 1e3,
|
|
183
|
+
scope: json.scope ?? creds.scope,
|
|
184
|
+
// Google does not return a new refresh_token on refresh; keep the old one.
|
|
185
|
+
refreshToken: json.refresh_token ?? creds.refreshToken
|
|
186
|
+
};
|
|
187
|
+
saveCreds(updated);
|
|
188
|
+
return updated;
|
|
189
|
+
}
|
|
190
|
+
async function getAccessToken() {
|
|
191
|
+
const config = loadConfig();
|
|
192
|
+
if (!config) throw new Error("Not configured. Run `health-agent auth:setup` first.");
|
|
193
|
+
let creds = loadCreds();
|
|
194
|
+
if (!creds) throw new Error("Not logged in. Run `health-agent auth:login` first.");
|
|
195
|
+
if (Date.now() >= creds.expiresAt - 6e4) {
|
|
196
|
+
creds = await refresh(config, creds);
|
|
197
|
+
}
|
|
198
|
+
return creds.accessToken;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/commands/auth.ts
|
|
202
|
+
function authSetup(args) {
|
|
203
|
+
if (!args.clientId || !args.clientSecret) {
|
|
204
|
+
console.error("\u274C --client-id and --client-secret are required.");
|
|
205
|
+
console.error(' Create an OAuth "Desktop app" client in your Google Cloud project:');
|
|
206
|
+
console.error(" https://developers.google.com/health/setup");
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
const scopes = args.scopes ? args.scopes.split(/[ ,]+/).filter(Boolean) : DEFAULT_SCOPES;
|
|
210
|
+
saveConfig({ clientId: args.clientId, clientSecret: args.clientSecret, scopes });
|
|
211
|
+
console.error("\u2705 OAuth client saved to ~/.health-agent/config.json");
|
|
212
|
+
console.error(` Scopes: ${scopes.length} requested.`);
|
|
213
|
+
console.error(" Next: health-agent auth:login");
|
|
214
|
+
}
|
|
215
|
+
async function authLogin() {
|
|
216
|
+
const config = loadConfig();
|
|
217
|
+
if (!config) {
|
|
218
|
+
console.error("\u274C Not configured. Run `health-agent auth:setup` first.");
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const creds = await login();
|
|
223
|
+
console.error("\n\u2705 Logged in. Token stored in ~/.health-agent/credentials.json");
|
|
224
|
+
console.error(` Granted scopes: ${creds.scope}`);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error(`
|
|
227
|
+
\u274C Login failed: ${e.message}`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function authStatus() {
|
|
232
|
+
const config = loadConfig();
|
|
233
|
+
const creds = loadCreds();
|
|
234
|
+
const status = {
|
|
235
|
+
configured: !!config,
|
|
236
|
+
clientId: config?.clientId ? config.clientId.replace(/(.{8}).*/, "$1\u2026") : null,
|
|
237
|
+
loggedIn: !!creds,
|
|
238
|
+
scope: creds?.scope ?? null,
|
|
239
|
+
expiresAt: creds ? new Date(creds.expiresAt).toISOString() : null,
|
|
240
|
+
expired: creds ? Date.now() >= creds.expiresAt : null,
|
|
241
|
+
hasRefreshToken: !!creds?.refreshToken
|
|
242
|
+
};
|
|
243
|
+
console.log(JSON.stringify(status, null, 2));
|
|
244
|
+
}
|
|
245
|
+
function authLogout() {
|
|
246
|
+
clearCreds();
|
|
247
|
+
console.error("\u2705 Credentials cleared (OAuth client config kept).");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/api.ts
|
|
251
|
+
var BASE_URL = "https://health.googleapis.com/v4";
|
|
252
|
+
var GOOGLE_WEARABLES = "users/me/dataSourceFamilies/google-wearables";
|
|
253
|
+
async function authedGet(pathAndQuery) {
|
|
254
|
+
const token = await getAccessToken();
|
|
255
|
+
const url = `${BASE_URL}/${pathAndQuery}`;
|
|
256
|
+
const resp = await fetch(url, {
|
|
257
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }
|
|
258
|
+
});
|
|
259
|
+
const text = await resp.text();
|
|
260
|
+
if (!resp.ok) {
|
|
261
|
+
throw new Error(`Google Health API ${resp.status} for ${url}
|
|
262
|
+
${text}`);
|
|
263
|
+
}
|
|
264
|
+
return text ? JSON.parse(text) : {};
|
|
265
|
+
}
|
|
266
|
+
async function getDataPoints(dataType, opts = {}) {
|
|
267
|
+
const params = new URLSearchParams();
|
|
268
|
+
if (opts.filter) params.set("filter", opts.filter);
|
|
269
|
+
if (opts.limit) params.set("pageSize", String(opts.limit));
|
|
270
|
+
if (opts.pageToken) params.set("pageToken", opts.pageToken);
|
|
271
|
+
if (opts.reconcile && opts.dataSourceFamily) params.set("dataSourceFamily", opts.dataSourceFamily);
|
|
272
|
+
for (const [k, v] of Object.entries(opts.extra ?? {})) params.set(k, v);
|
|
273
|
+
const verb = opts.reconcile ? "dataPoints:reconcile" : "dataPoints";
|
|
274
|
+
const qs = params.toString();
|
|
275
|
+
return authedGet(`users/me/dataTypes/${dataType}/${verb}${qs ? `?${qs}` : ""}`);
|
|
276
|
+
}
|
|
277
|
+
async function rawGet(pathAndQuery) {
|
|
278
|
+
return authedGet(pathAndQuery.replace(/^\/+/, ""));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/commands/data.ts
|
|
282
|
+
function toOpts(args) {
|
|
283
|
+
const reconcile = args.reconcile || args.wearables || !!args.source;
|
|
284
|
+
return {
|
|
285
|
+
reconcile,
|
|
286
|
+
dataSourceFamily: args.source ?? (args.wearables ? GOOGLE_WEARABLES : void 0),
|
|
287
|
+
filter: args.filter,
|
|
288
|
+
limit: args.limit,
|
|
289
|
+
pageToken: args.pageToken
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function emit(data) {
|
|
293
|
+
console.log(JSON.stringify(data, null, 2));
|
|
294
|
+
}
|
|
295
|
+
async function getData(dataType, args) {
|
|
296
|
+
try {
|
|
297
|
+
emit(await getDataPoints(dataType, toOpts(args)));
|
|
298
|
+
} catch (e) {
|
|
299
|
+
console.error(`\u274C ${e.message}`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function getRaw(path2) {
|
|
304
|
+
try {
|
|
305
|
+
emit(await rawGet(path2));
|
|
306
|
+
} catch (e) {
|
|
307
|
+
console.error(`\u274C ${e.message}`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function getProfile() {
|
|
312
|
+
await getRaw("users/me/profile");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/index.ts
|
|
316
|
+
var dataOpts = (y) => y.option("reconcile", {
|
|
317
|
+
describe: "Use the reconciled stream (deduped \u2014 matches what the Fitbit app shows)",
|
|
318
|
+
type: "boolean",
|
|
319
|
+
default: false
|
|
320
|
+
}).option("wearables", {
|
|
321
|
+
alias: "w",
|
|
322
|
+
describe: "Shortcut for --reconcile against the google-wearables (Fitbit/Pixel) source",
|
|
323
|
+
type: "boolean",
|
|
324
|
+
default: false
|
|
325
|
+
}).option("source", {
|
|
326
|
+
describe: "Restrict to a data source family (implies --reconcile), e.g. users/me/dataSourceFamilies/google-wearables",
|
|
327
|
+
type: "string"
|
|
328
|
+
}).option("filter", {
|
|
329
|
+
alias: "f",
|
|
330
|
+
describe: `Google Health filter, e.g. 'sleep.interval.civil_end_time >= "2026-03-03"'`,
|
|
331
|
+
type: "string"
|
|
332
|
+
}).option("limit", { alias: "n", describe: "Max data points (pageSize)", type: "number" }).option("page-token", { describe: "Pagination token from a previous response", type: "string" });
|
|
333
|
+
yargs(hideBin(process.argv)).scriptName("health-agent").usage("$0 <command> [options]").command(
|
|
334
|
+
"auth:setup",
|
|
335
|
+
"Store your Google Cloud OAuth client (Desktop app type)",
|
|
336
|
+
(y) => y.option("client-id", { describe: "OAuth client ID", type: "string" }).option("client-secret", { describe: "OAuth client secret", type: "string" }).option("scopes", { describe: "Space/comma separated scope override", type: "string" }),
|
|
337
|
+
(a) => authSetup({ clientId: a.clientId, clientSecret: a.clientSecret, scopes: a.scopes })
|
|
338
|
+
).command("auth:login", "Authorize via browser and store tokens", {}, () => authLogin()).command("auth:status", "Show auth/config status (JSON)", {}, () => authStatus()).command("auth:logout", "Clear stored credentials", {}, () => authLogout()).command(
|
|
339
|
+
"get <dataType>",
|
|
340
|
+
"Fetch data points for any data type (e.g. steps, sleep, exercise, body-fat)",
|
|
341
|
+
(y) => dataOpts(
|
|
342
|
+
y.positional("dataType", {
|
|
343
|
+
describe: "Data type id (kebab-case), e.g. steps | sleep | exercise | body-fat",
|
|
344
|
+
type: "string"
|
|
345
|
+
})
|
|
346
|
+
),
|
|
347
|
+
(a) => getData(a.dataType, {
|
|
348
|
+
reconcile: a.reconcile,
|
|
349
|
+
wearables: a.wearables,
|
|
350
|
+
source: a.source,
|
|
351
|
+
filter: a.filter,
|
|
352
|
+
limit: a.limit,
|
|
353
|
+
pageToken: a.pageToken
|
|
354
|
+
})
|
|
355
|
+
).command(
|
|
356
|
+
"steps",
|
|
357
|
+
"Fetch step data (reconciled, Fitbit/Pixel)",
|
|
358
|
+
dataOpts,
|
|
359
|
+
(a) => getData("steps", { ...a, wearables: a.wearables || !a.reconcile && !a.source })
|
|
360
|
+
).command(
|
|
361
|
+
"sleep",
|
|
362
|
+
"Fetch sleep data (reconciled, Fitbit/Pixel)",
|
|
363
|
+
dataOpts,
|
|
364
|
+
(a) => getData("sleep", { ...a, wearables: a.wearables || !a.reconcile && !a.source })
|
|
365
|
+
).command(
|
|
366
|
+
"exercise",
|
|
367
|
+
"Fetch exercise/activity sessions (reconciled, Fitbit/Pixel)",
|
|
368
|
+
dataOpts,
|
|
369
|
+
(a) => getData("exercise", { ...a, wearables: a.wearables || !a.reconcile && !a.source })
|
|
370
|
+
).command("profile", "Fetch your user profile", {}, () => getProfile()).command(
|
|
371
|
+
"raw <path>",
|
|
372
|
+
'GET any path under the v4 base, e.g. "users/me/dataTypes/steps/dataPoints"',
|
|
373
|
+
(y) => y.positional("path", { describe: "Path after https://health.googleapis.com/v4/", type: "string" }),
|
|
374
|
+
(a) => getRaw(a.path)
|
|
375
|
+
).demandCommand(1, "Run a command. Try `health-agent --help`.").strict().help().alias("h", "help").wrap(Math.min(120, process.stdout.columns ?? 120)).parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "health-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI + Claude skill to authenticate with the Google Health API and pull your Fitbit / Pixel health data",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"health-agent": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "tsup --watch",
|
|
12
|
+
"build": "tsup",
|
|
13
|
+
"start": "node ./dist/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"SKILL.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"fitbit",
|
|
24
|
+
"google-health",
|
|
25
|
+
"health",
|
|
26
|
+
"cli",
|
|
27
|
+
"ai-agent",
|
|
28
|
+
"oauth",
|
|
29
|
+
"quantified-self"
|
|
30
|
+
],
|
|
31
|
+
"author": "Linh Phung",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/codevagabond/health-agent.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/codevagabond/health-agent#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/codevagabond/health-agent/issues"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"yargs": "^17.7.2"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^20.11.19",
|
|
52
|
+
"@types/yargs": "^17.0.32",
|
|
53
|
+
"tsup": "^8.5.1",
|
|
54
|
+
"typescript": "^5.3.3"
|
|
55
|
+
}
|
|
56
|
+
}
|