@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 +21 -0
- package/README.md +339 -0
- package/dist/auth/oauth.js +88 -0
- package/dist/auth/refresh-lock.js +19 -0
- package/dist/auth/token-service.js +59 -0
- package/dist/cli.js +37 -0
- package/dist/commands/activity.js +44 -0
- package/dist/commands/auth.js +175 -0
- package/dist/commands/behavior.js +87 -0
- package/dist/commands/context.js +39 -0
- package/dist/commands/cycle.js +58 -0
- package/dist/commands/experiment.js +122 -0
- package/dist/commands/health.js +136 -0
- package/dist/commands/openclaw.js +49 -0
- package/dist/commands/profile.js +31 -0
- package/dist/commands/recovery.js +58 -0
- package/dist/commands/sleep.js +92 -0
- package/dist/commands/summary.js +115 -0
- package/dist/commands/sync.js +57 -0
- package/dist/commands/webhook.js +38 -0
- package/dist/commands/workout.js +77 -0
- package/dist/http/client.js +110 -0
- package/dist/http/errors.js +27 -0
- package/dist/http/whoop-data.js +28 -0
- package/dist/index.js +7 -0
- package/dist/models/whoop.js +1 -0
- package/dist/output/envelope.js +15 -0
- package/dist/store/profile-store.js +19 -0
- package/dist/types.js +1 -0
- package/dist/util/config.js +8 -0
- package/dist/util/fs.js +25 -0
- package/dist/util/metrics.js +21 -0
- package/dist/util/open-browser.js +19 -0
- package/dist/util/prompt.js +12 -0
- package/dist/util/time.js +47 -0
- package/dist/util/webhook-signature.js +32 -0
- package/openclaw-skill/SKILL.md +33 -0
- package/package.json +37 -0
- package/scripts/whoop-refresh-monitor.sh +13 -0
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
|
+
};
|