@andreasnlarsen/whoop-cli 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
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,339 @@
1
+ # whoop-cli
2
+
3
+ Simple WHOOP command-line tool for humans and agents.
4
+
5
+ It gives you:
6
+ - easy OAuth login
7
+ - daily readiness commands (`day-brief`, `summary`, `health flags`)
8
+ - machine-safe JSON output (`{data,error}`)
9
+ - export + webhook verification tools
10
+
11
+ ---
12
+
13
+ ## Important: auth model (for now)
14
+
15
+ This project is currently **BYO WHOOP app credentials**.
16
+
17
+ That means each user (or each installer/agent) must create a WHOOP Developer app and use its:
18
+ - Client ID
19
+ - Client Secret
20
+ - Redirect URI
21
+
22
+ There is **no managed/shared auth service** in this repo right now.
23
+
24
+ ## Important legal / brand notice
25
+
26
+ - This project is **unofficial** and is **not affiliated with, endorsed by, or sponsored by Whoop, Inc.**
27
+ - **WHOOP** is a trademark of Whoop, Inc., used here for compatibility/reference only.
28
+ - This CLI is built to work with the WHOOP developer API, but you are responsible for complying with:
29
+ - WHOOP API Terms of Use
30
+ - WHOOP brand/design guidelines
31
+ - applicable privacy and data-protection laws
32
+ - Do **not** embed or publish client secrets/tokens in source code, examples, or public logs.
33
+ - If WHOOP requests naming/branding/compliance changes, maintainers should address them promptly and cooperatively.
34
+
35
+ ---
36
+
37
+ ## One-line options (no clone)
38
+
39
+ If you want agents/users to run it immediately without cloning:
40
+
41
+ ### Current (works now via GitHub source)
42
+
43
+ Run once (ephemeral):
44
+
45
+ ```bash
46
+ npm exec --yes --package=github:andreasnlarsen/whoop-cli -- whoop summary --json --pretty
47
+ ```
48
+
49
+ Install globally:
50
+
51
+ ```bash
52
+ npm install -g github:andreasnlarsen/whoop-cli
53
+ ```
54
+
55
+ ### After npm publish (recommended)
56
+
57
+ Run once (ephemeral):
58
+
59
+ ```bash
60
+ npx -y @andreasnlarsen/whoop-cli summary --json --pretty
61
+ ```
62
+
63
+ Install globally:
64
+
65
+ ```bash
66
+ npm install -g @andreasnlarsen/whoop-cli
67
+ ```
68
+
69
+ Then use:
70
+
71
+ ```bash
72
+ whoop --help
73
+ ```
74
+
75
+ ### OpenClaw skill install (optional)
76
+
77
+ After global install, copy bundled skill into OpenClaw workspace:
78
+
79
+ ```bash
80
+ whoop openclaw install-skill --force
81
+ ```
82
+
83
+ (Default target: `~/.openclaw/workspace/skills/whoop-cli/SKILL.md`)
84
+
85
+ ---
86
+
87
+ ## Quick start
88
+
89
+ ## 1) Install
90
+
91
+ ```bash
92
+ npm install
93
+ npm run build
94
+ ```
95
+
96
+ Run help:
97
+
98
+ ```bash
99
+ node dist/index.js --help
100
+ ```
101
+
102
+ (If installed globally later, use `whoop ...` directly.)
103
+
104
+ ### Command name
105
+
106
+ The executable is `whoop` (not `whoop-cli`).
107
+
108
+ ## 2) Create WHOOP app
109
+
110
+ Open: https://developer-dashboard.whoop.com/
111
+
112
+ Create an app and set these fields:
113
+
114
+ - **App name:** anything (example: `whoop-cli`)
115
+ - **Redirect URI:** use one value and keep it consistent
116
+ - recommended: `http://localhost:1234/callback`
117
+ - accepted alternative: `https://localhost:1234/callback`
118
+ - **Scopes:** include at least
119
+ - `read:recovery`
120
+ - `read:cycles`
121
+ - `read:workout`
122
+ - `read:sleep`
123
+ - `read:profile`
124
+ - `read:body_measurement`
125
+ - `offline` (for refresh token)
126
+
127
+ Then copy these 3 values from WHOOP dashboard:
128
+ - client id
129
+ - client secret
130
+ - redirect URI
131
+
132
+ ## 3) Login
133
+
134
+ ```bash
135
+ whoop auth login \
136
+ --client-id "<CLIENT_ID>" \
137
+ --client-secret "<CLIENT_SECRET>" \
138
+ --redirect-uri "<REDIRECT_URI>"
139
+ ```
140
+
141
+ Then test:
142
+
143
+ ```bash
144
+ whoop auth status --json --pretty
145
+ whoop day-brief --json --pretty
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Redirect URI: what to use
151
+
152
+ This is the #1 setup confusion, so here is the practical rule:
153
+
154
+ - The redirect URI in WHOOP Dashboard and CLI must **match exactly**.
155
+ - `whoop-cli` currently uses a **manual paste flow** (it does not require a running callback server).
156
+
157
+ ### Recommended default
158
+
159
+ Use:
160
+
161
+ `http://localhost:1234/callback`
162
+
163
+ Set this in WHOOP Dashboard, and pass the same value to `whoop auth login`.
164
+
165
+ ### If localhost is blocked by your policy
166
+
167
+ Use any stable URI you control, for example:
168
+
169
+ `https://your-domain.com/whoop/callback`
170
+
171
+ Again, pass the exact same value in CLI.
172
+
173
+ ### What happens during login?
174
+
175
+ After WHOOP consent, browser redirects to that URI with `?code=...&state=...`.
176
+ Copy the **full redirected URL** from the browser address bar and paste it into the CLI prompt.
177
+
178
+ (If localhost page fails to load, that is usually fine—just copy the URL.)
179
+
180
+ ---
181
+
182
+ ## For non-technical users
183
+
184
+ If an agent/dev is installing for you, send them these 3 values only:
185
+ 1. Client ID
186
+ 2. Client Secret
187
+ 3. Redirect URI
188
+
189
+ They run login once, then you can use ready commands like:
190
+
191
+ ```bash
192
+ whoop summary --json --pretty
193
+ whoop day-brief --json --pretty
194
+ ```
195
+
196
+ ---
197
+
198
+ ## For agents/installers (recommended setup flow)
199
+
200
+ 1. Verify auth:
201
+
202
+ ```bash
203
+ whoop auth status --json
204
+ ```
205
+
206
+ 2. If not authenticated, run `whoop auth login ...`
207
+ 3. Validate with:
208
+
209
+ ```bash
210
+ whoop profile show --json
211
+ whoop day-brief --json
212
+ ```
213
+
214
+ 4. For unattended systems, schedule:
215
+
216
+ ```bash
217
+ scripts/whoop-refresh-monitor.sh
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Most useful commands
223
+
224
+ ### Daily coaching
225
+ - `whoop summary`
226
+ - `whoop day-brief`
227
+ - `whoop strain-plan`
228
+ - `whoop health flags`
229
+
230
+ ### Core data
231
+ - `whoop profile show`
232
+ - `whoop recovery latest|list`
233
+ - `whoop sleep latest|list|trend`
234
+ - `whoop cycle latest|list`
235
+ - `whoop workout list|trend`
236
+
237
+ ### Ops
238
+ - `whoop sync pull --start YYYY-MM-DD --end YYYY-MM-DD --out ./whoop.jsonl`
239
+ - `whoop webhook verify --secret ... --timestamp ... --signature ... --body-file ...`
240
+ - `whoop activity map-v1-id --id <legacyV1ActivityId>`
241
+ - `whoop openclaw install-skill --force`
242
+
243
+ ### Behavior/experiments
244
+ - `whoop behavior impacts --file ~/.whoop-cli/journal-observations.jsonl`
245
+ - `whoop experiment start --name ... --behavior ...`
246
+ - `whoop experiment list`
247
+ - `whoop experiment report --id ...`
248
+
249
+ ---
250
+
251
+ ## JSON output contract
252
+
253
+ With `--json`, every command returns:
254
+
255
+ ```json
256
+ { "data": {"...": "..."}, "error": null }
257
+ ```
258
+
259
+ or
260
+
261
+ ```json
262
+ {
263
+ "data": null,
264
+ "error": {
265
+ "code": "AUTH_ERROR",
266
+ "message": "...",
267
+ "details": {"...": "..."}
268
+ }
269
+ }
270
+ ```
271
+
272
+ Exit codes:
273
+ - `0` success
274
+ - `2` usage/config/feature-unavailable
275
+ - `3` auth
276
+ - `4` api/network
277
+ - `1` unexpected internal
278
+
279
+ ---
280
+
281
+ ## Security
282
+
283
+ - Tokens saved in `~/.whoop-cli/profiles/<name>.json` with strict file permissions
284
+ - Refresh-token flow supported for automation
285
+ - CLI avoids printing secrets by default
286
+
287
+ ---
288
+
289
+ ## Maintainer release (npm)
290
+
291
+ ### Option A: Trusted publisher (recommended)
292
+
293
+ This repo includes: `.github/workflows/npm-publish.yml`.
294
+
295
+ One-time setup on npmjs.com (**required**):
296
+
297
+ 1. Go to package settings for `@andreasnlarsen/whoop-cli`
298
+ 2. Add trusted publisher:
299
+ - Provider: GitHub Actions
300
+ - Organization/User: `andreasnlarsen`
301
+ - Repository: `whoop-cli`
302
+ - Workflow filename: `npm-publish.yml`
303
+ - Environment name: leave empty (or set if you enforce GitHub Environment)
304
+ 3. Optional hardening (recommended): package Settings → Publishing access →
305
+ - "Require two-factor authentication and disallow tokens"
306
+
307
+ Release flow:
308
+
309
+ ```bash
310
+ # bump version first (example)
311
+ npm version patch
312
+
313
+ git push origin main --follow-tags
314
+ # OR manually tag: git tag v0.1.1 && git push origin v0.1.1
315
+ ```
316
+
317
+ The GitHub workflow will publish automatically on `v*` tags via OIDC.
318
+
319
+ ### Bootstrap note (first publish)
320
+
321
+ npm currently requires the package to exist before trusted publisher can be configured in package settings.
322
+ If this is your very first publish for this package, do one manual publish first:
323
+
324
+ ```bash
325
+ npm login
326
+ ./scripts/publish-npm.sh
327
+ ```
328
+
329
+ Then enable trusted publisher and use tag-based releases going forward.
330
+
331
+ ## Sources
332
+
333
+ - https://developer.whoop.com/api/
334
+ - https://developer.whoop.com/docs/developing/oauth/
335
+ - https://developer.whoop.com/docs/developing/webhooks/
336
+ - https://developer.whoop.com/docs/developing/getting-started/
337
+ - https://developer.whoop.com/api-terms-of-use/
338
+ - https://developer.whoop.com/docs/developing/design-guidelines/
339
+ - https://developer.whoop.com/docs/developing/app-approval/
@@ -0,0 +1,88 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { authError, networkError, usageError } from '../http/errors.js';
3
+ export const generateState = () => randomBytes(16).toString('hex').slice(0, 16);
4
+ export const buildAuthUrl = (config, scopes, state) => {
5
+ const url = new URL('/oauth/oauth2/auth', config.baseUrl);
6
+ url.searchParams.set('response_type', 'code');
7
+ url.searchParams.set('client_id', config.clientId);
8
+ url.searchParams.set('redirect_uri', config.redirectUri);
9
+ url.searchParams.set('scope', scopes.join(' '));
10
+ url.searchParams.set('state', state);
11
+ return url.toString();
12
+ };
13
+ const tokenEndpoint = (baseUrl) => new URL('/oauth/oauth2/token', baseUrl).toString();
14
+ const exchange = async (config, payload) => {
15
+ const body = new URLSearchParams(payload);
16
+ let res;
17
+ try {
18
+ res = await fetch(tokenEndpoint(config.baseUrl), {
19
+ method: 'POST',
20
+ headers: {
21
+ 'content-type': 'application/x-www-form-urlencoded',
22
+ },
23
+ body: body.toString(),
24
+ });
25
+ }
26
+ catch (err) {
27
+ throw networkError('Failed to reach WHOOP token endpoint', {
28
+ cause: err instanceof Error ? err.message : String(err),
29
+ });
30
+ }
31
+ const raw = await res.text();
32
+ let parsed = raw;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ // keep raw
38
+ }
39
+ if (!res.ok) {
40
+ throw authError('WHOOP token exchange failed', {
41
+ status: res.status,
42
+ response: parsed,
43
+ });
44
+ }
45
+ const token = parsed;
46
+ if (!token.access_token || !token.expires_in || !token.token_type) {
47
+ throw authError('WHOOP token response missing required fields', { response: parsed });
48
+ }
49
+ return token;
50
+ };
51
+ export const exchangeAuthCode = async (config, code) => {
52
+ if (!code)
53
+ throw usageError('Authorization code is required');
54
+ return exchange(config, {
55
+ grant_type: 'authorization_code',
56
+ code,
57
+ client_id: config.clientId,
58
+ client_secret: config.clientSecret,
59
+ redirect_uri: config.redirectUri,
60
+ });
61
+ };
62
+ export const refreshAuthToken = async (config, refreshToken, scope) => {
63
+ if (!refreshToken)
64
+ throw usageError('Refresh token is required');
65
+ return exchange(config, {
66
+ grant_type: 'refresh_token',
67
+ refresh_token: refreshToken,
68
+ client_id: config.clientId,
69
+ client_secret: config.clientSecret,
70
+ ...(scope ? { scope } : {}),
71
+ });
72
+ };
73
+ export const parseAuthInput = (input) => {
74
+ const trimmed = input.trim();
75
+ if (!trimmed) {
76
+ throw usageError('Empty authorization input');
77
+ }
78
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
79
+ const url = new URL(trimmed);
80
+ const code = url.searchParams.get('code');
81
+ const state = url.searchParams.get('state') ?? undefined;
82
+ if (!code) {
83
+ throw usageError('Redirect URL did not contain code parameter');
84
+ }
85
+ return { code, state };
86
+ }
87
+ return { code: trimmed };
88
+ };
@@ -0,0 +1,19 @@
1
+ const locks = new Map();
2
+ export const withRefreshLock = async (key, fn) => {
3
+ const current = locks.get(key);
4
+ if (current) {
5
+ await current;
6
+ }
7
+ let resolve;
8
+ const gate = new Promise((r) => {
9
+ resolve = r;
10
+ });
11
+ locks.set(key, gate);
12
+ try {
13
+ return await fn();
14
+ }
15
+ finally {
16
+ locks.delete(key);
17
+ resolve();
18
+ }
19
+ };
@@ -0,0 +1,59 @@
1
+ import { refreshAuthToken } from './oauth.js';
2
+ import { withRefreshLock } from './refresh-lock.js';
3
+ import { loadProfile, saveProfile } from '../store/profile-store.js';
4
+ import { authError, configError } from '../http/errors.js';
5
+ import { tokenRefreshSkewSeconds } from '../util/config.js';
6
+ const nowEpochSeconds = () => Math.floor(Date.now() / 1000);
7
+ const expiresAtToEpoch = (iso) => Math.floor(new Date(iso).getTime() / 1000);
8
+ export const isTokenExpired = (token, skewSeconds = tokenRefreshSkewSeconds) => {
9
+ const exp = expiresAtToEpoch(token.expiresAt);
10
+ return exp - nowEpochSeconds() <= skewSeconds;
11
+ };
12
+ export const tokenFromOAuth = (payload, previousRefreshToken) => {
13
+ const expiresAt = new Date(Date.now() + payload.expires_in * 1000).toISOString();
14
+ return {
15
+ accessToken: payload.access_token,
16
+ refreshToken: payload.refresh_token ?? previousRefreshToken,
17
+ tokenType: payload.token_type,
18
+ scope: payload.scope,
19
+ expiresAt,
20
+ };
21
+ };
22
+ export const requireProfile = async (profileName) => {
23
+ const profile = await loadProfile(profileName);
24
+ if (!profile) {
25
+ throw configError(`Profile "${profileName}" was not found. Run whoop auth login first.`);
26
+ }
27
+ if (!profile.clientId || !profile.clientSecret || !profile.redirectUri) {
28
+ throw configError(`Profile "${profileName}" is missing OAuth client settings.`);
29
+ }
30
+ return profile;
31
+ };
32
+ const toOAuthConfig = (profile) => ({
33
+ clientId: profile.clientId,
34
+ clientSecret: profile.clientSecret,
35
+ redirectUri: profile.redirectUri,
36
+ baseUrl: profile.baseUrl,
37
+ });
38
+ export const refreshProfileToken = async (profileName) => withRefreshLock(profileName, async () => {
39
+ const profile = await requireProfile(profileName);
40
+ const refreshToken = profile.tokens?.refreshToken;
41
+ if (!refreshToken) {
42
+ throw authError('No refresh token available. Re-run whoop auth login with offline scope.');
43
+ }
44
+ const refreshed = await refreshAuthToken(toOAuthConfig(profile), refreshToken, profile.tokens?.scope);
45
+ profile.tokens = tokenFromOAuth(refreshed, refreshToken);
46
+ await saveProfile(profileName, profile);
47
+ return profile;
48
+ });
49
+ export const ensureFreshToken = async (profileName) => {
50
+ const profile = await requireProfile(profileName);
51
+ const token = profile.tokens;
52
+ if (!token?.accessToken) {
53
+ throw authError('No access token found. Run whoop auth login.');
54
+ }
55
+ if (!isTokenExpired(token)) {
56
+ return profile;
57
+ }
58
+ return refreshProfileToken(profileName);
59
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,37 @@
1
+ import { Command } from 'commander';
2
+ import { registerAuthCommands } from './commands/auth.js';
3
+ import { registerProfileCommands } from './commands/profile.js';
4
+ import { registerRecoveryCommands } from './commands/recovery.js';
5
+ import { registerSleepCommands } from './commands/sleep.js';
6
+ import { registerCycleCommands } from './commands/cycle.js';
7
+ import { registerWorkoutCommands } from './commands/workout.js';
8
+ import { registerSummaryCommands } from './commands/summary.js';
9
+ import { registerHealthCommands } from './commands/health.js';
10
+ import { registerSyncCommands } from './commands/sync.js';
11
+ import { registerWebhookCommands } from './commands/webhook.js';
12
+ import { registerBehaviorCommands } from './commands/behavior.js';
13
+ import { registerExperimentCommands } from './commands/experiment.js';
14
+ import { registerActivityCommands } from './commands/activity.js';
15
+ import { registerOpenClawCommands } from './commands/openclaw.js';
16
+ export const program = new Command()
17
+ .name('whoop')
18
+ .description('WHOOP CLI for human + agent workflows')
19
+ .option('--json', 'Output JSON envelope', false)
20
+ .option('--pretty', 'Pretty print JSON', false)
21
+ .option('--profile <name>', 'Profile name', 'default')
22
+ .option('--base-url <url>', 'WHOOP API base URL', 'https://api.prod.whoop.com')
23
+ .option('--timeout-ms <n>', 'HTTP timeout in ms', '10000');
24
+ registerAuthCommands(program);
25
+ registerProfileCommands(program);
26
+ registerRecoveryCommands(program);
27
+ registerSleepCommands(program);
28
+ registerCycleCommands(program);
29
+ registerWorkoutCommands(program);
30
+ registerSummaryCommands(program);
31
+ registerHealthCommands(program);
32
+ registerSyncCommands(program);
33
+ registerWebhookCommands(program);
34
+ registerBehaviorCommands(program);
35
+ registerExperimentCommands(program);
36
+ registerActivityCommands(program);
37
+ registerOpenClawCommands(program);
@@ -0,0 +1,44 @@
1
+ import { WhoopApiClient } from '../http/client.js';
2
+ import { getGlobalOptions, printData, printError } from './context.js';
3
+ import { WhoopCliError, usageError } from '../http/errors.js';
4
+ export const registerActivityCommands = (program) => {
5
+ const activity = program.command('activity').description('Activity migration and lookup helpers');
6
+ activity
7
+ .command('map-v1-id')
8
+ .description('Lookup v2 activity UUID from legacy v1 activity ID')
9
+ .requiredOption('--id <activityV1Id>', 'legacy v1 activity id')
10
+ .action(async function mapV1IdAction(opts) {
11
+ const globals = getGlobalOptions(this);
12
+ const id = Number(opts.id);
13
+ try {
14
+ if (Number.isNaN(id) || id <= 0) {
15
+ throw usageError('id must be a positive integer', { value: opts.id });
16
+ }
17
+ const client = new WhoopApiClient(globals.profile);
18
+ const data = await client.requestJson({
19
+ path: `/developer/v1/activity-mapping/${id}`,
20
+ timeoutMs: globals.timeoutMs,
21
+ });
22
+ printData(this, {
23
+ activityV1Id: id,
24
+ activityV2Id: data.v2_activity_id ?? null,
25
+ found: Boolean(data.v2_activity_id),
26
+ });
27
+ }
28
+ catch (err) {
29
+ if (err instanceof WhoopCliError &&
30
+ err.code === 'HTTP_ERROR' &&
31
+ typeof err.details === 'object' &&
32
+ err.details !== null &&
33
+ err.details.status === 404) {
34
+ printData(this, {
35
+ activityV1Id: id,
36
+ activityV2Id: null,
37
+ found: false,
38
+ });
39
+ return;
40
+ }
41
+ printError(this, err);
42
+ }
43
+ });
44
+ };