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 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
+ }