@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.
@@ -0,0 +1,19 @@
1
+ import { profilePath } from '../util/config.js';
2
+ import { readJsonFile, writeJsonFileSecure } from '../util/fs.js';
3
+ export const loadProfile = async (name) => {
4
+ return readJsonFile(profilePath(name));
5
+ };
6
+ export const saveProfile = async (name, profile) => {
7
+ await writeJsonFileSecure(profilePath(name), {
8
+ ...profile,
9
+ profileName: name,
10
+ updatedAt: new Date().toISOString(),
11
+ });
12
+ };
13
+ export const clearProfileTokens = async (name) => {
14
+ const profile = await loadProfile(name);
15
+ if (!profile)
16
+ return;
17
+ delete profile.tokens;
18
+ await saveProfile(name, profile);
19
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ export const DEFAULT_BASE_URL = 'https://api.prod.whoop.com';
4
+ export const whoopHome = () => join(homedir(), '.whoop-cli');
5
+ export const profilePath = (profile) => join(whoopHome(), 'profiles', `${profile}.json`);
6
+ export const experimentsPath = () => join(whoopHome(), 'experiments.json');
7
+ export const behaviorLogPath = () => join(whoopHome(), 'journal-observations.jsonl');
8
+ export const tokenRefreshSkewSeconds = 120;
@@ -0,0 +1,25 @@
1
+ import { mkdir, readFile, writeFile, chmod, rename } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ export const ensureDir = async (path) => {
4
+ await mkdir(path, { recursive: true });
5
+ };
6
+ export const readJsonFile = async (path) => {
7
+ try {
8
+ const raw = await readFile(path, 'utf8');
9
+ return JSON.parse(raw);
10
+ }
11
+ catch (err) {
12
+ const code = err.code;
13
+ if (code === 'ENOENT')
14
+ return null;
15
+ throw err;
16
+ }
17
+ };
18
+ export const writeJsonFileSecure = async (path, value) => {
19
+ await ensureDir(dirname(path));
20
+ const tmpPath = `${path}.tmp-${Date.now()}`;
21
+ const body = JSON.stringify(value, null, 2);
22
+ await writeFile(tmpPath, body, { mode: 0o600 });
23
+ await chmod(tmpPath, 0o600);
24
+ await rename(tmpPath, path);
25
+ };
@@ -0,0 +1,21 @@
1
+ export const avg = (values) => {
2
+ const valid = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
3
+ if (!valid.length)
4
+ return null;
5
+ return valid.reduce((a, b) => a + b, 0) / valid.length;
6
+ };
7
+ export const round = (value, digits = 1) => {
8
+ if (value === null)
9
+ return null;
10
+ const p = 10 ** digits;
11
+ return Math.round(value * p) / p;
12
+ };
13
+ export const classifyRecovery = (score) => {
14
+ if (typeof score !== 'number')
15
+ return 'unknown';
16
+ if (score >= 67)
17
+ return 'green';
18
+ if (score >= 34)
19
+ return 'yellow';
20
+ return 'red';
21
+ };
@@ -0,0 +1,19 @@
1
+ import { spawn } from 'node:child_process';
2
+ export const tryOpenBrowser = (url) => {
3
+ const cmds = [
4
+ ['xdg-open', [url]],
5
+ ['open', [url]],
6
+ ['cmd', ['/c', 'start', '', url]],
7
+ ];
8
+ for (const [cmd, args] of cmds) {
9
+ try {
10
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
11
+ child.unref();
12
+ return true;
13
+ }
14
+ catch {
15
+ // try next
16
+ }
17
+ }
18
+ return false;
19
+ };
@@ -0,0 +1,12 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ export const ask = async (question) => {
4
+ const rl = createInterface({ input, output });
5
+ try {
6
+ const answer = await rl.question(question);
7
+ return answer;
8
+ }
9
+ finally {
10
+ rl.close();
11
+ }
12
+ };
@@ -0,0 +1,47 @@
1
+ import { usageError } from '../http/errors.js';
2
+ const DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
3
+ export const isIsoDate = (value) => DATE_RX.test(value);
4
+ export const assertIsoDate = (value, label = 'date') => {
5
+ if (!isIsoDate(value)) {
6
+ throw usageError(`${label} must be in YYYY-MM-DD format`, { value });
7
+ }
8
+ return value;
9
+ };
10
+ export const dateToIso = (date) => {
11
+ const y = date.getUTCFullYear();
12
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
13
+ const d = String(date.getUTCDate()).padStart(2, '0');
14
+ return `${y}-${m}-${d}`;
15
+ };
16
+ export const daysAgoIso = (days, now = new Date()) => {
17
+ const ms = now.getTime() - days * 24 * 60 * 60 * 1000;
18
+ return dateToIso(new Date(ms));
19
+ };
20
+ export const parseDateRange = ({ start, end, days }) => {
21
+ if (start)
22
+ assertIsoDate(start, 'start');
23
+ if (end)
24
+ assertIsoDate(end, 'end');
25
+ if (days !== undefined && Number.isNaN(days)) {
26
+ throw usageError('days must be a number');
27
+ }
28
+ if (start || end) {
29
+ return { start, end };
30
+ }
31
+ if (days && days > 0) {
32
+ return {
33
+ start: daysAgoIso(days),
34
+ end: undefined,
35
+ };
36
+ }
37
+ return {};
38
+ };
39
+ export const parseMaybeNumber = (value) => {
40
+ if (value === undefined)
41
+ return undefined;
42
+ const n = Number(value);
43
+ if (Number.isNaN(n)) {
44
+ throw usageError('value must be numeric', { value });
45
+ }
46
+ return n;
47
+ };
@@ -0,0 +1,32 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { usageError } from '../http/errors.js';
4
+ export const buildWhoopSignature = (timestamp, rawBody, clientSecret) => {
5
+ const payload = `${timestamp}${rawBody}`;
6
+ const digest = createHmac('sha256', clientSecret).update(payload).digest('base64');
7
+ return digest;
8
+ };
9
+ const safeCompare = (a, b) => {
10
+ const aBuf = Buffer.from(a);
11
+ const bBuf = Buffer.from(b);
12
+ if (aBuf.length !== bBuf.length)
13
+ return false;
14
+ return timingSafeEqual(aBuf, bBuf);
15
+ };
16
+ export const verifyWhoopSignature = ({ timestamp, rawBody, clientSecret, signature, }) => {
17
+ const expected = buildWhoopSignature(timestamp, rawBody, clientSecret);
18
+ return safeCompare(expected, signature.trim());
19
+ };
20
+ export const readRawBodyFromFile = async (filePath) => {
21
+ if (!filePath)
22
+ throw usageError('body-file is required');
23
+ try {
24
+ return await readFile(filePath, 'utf8');
25
+ }
26
+ catch (err) {
27
+ throw usageError('Unable to read body file', {
28
+ filePath,
29
+ cause: err instanceof Error ? err.message : String(err),
30
+ });
31
+ }
32
+ };
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: whoop-cli
3
+ description: Use whoop-cli to fetch WHOOP data, generate day briefs/health flags, and export trend data for automation workflows.
4
+ ---
5
+
6
+ # whoop-cli
7
+
8
+ Use the installed `whoop` command.
9
+
10
+ ## Core checks
11
+
12
+ 1. `whoop auth status --json`
13
+ 2. If unauthenticated: `whoop auth login --client-id ... --client-secret ... --redirect-uri ...`
14
+ 3. Validate: `whoop day-brief --json --pretty`
15
+
16
+ ## Useful commands
17
+
18
+ - Daily:
19
+ - `whoop summary --json --pretty`
20
+ - `whoop day-brief --json --pretty`
21
+ - `whoop strain-plan --json --pretty`
22
+ - `whoop health flags --days 7 --json --pretty`
23
+ - Trends:
24
+ - `whoop sleep trend --days 30 --json --pretty`
25
+ - `whoop workout trend --days 14 --json --pretty`
26
+ - Export:
27
+ - `whoop sync pull --start YYYY-MM-DD --end YYYY-MM-DD --out ./whoop.jsonl --json --pretty`
28
+
29
+ ## Safety
30
+
31
+ - Never print client secrets or raw tokens.
32
+ - Keep API errors concise and actionable.
33
+ - Treat this integration as unofficial/non-affiliated.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@andreasnlarsen/whoop-cli",
3
+ "version": "0.1.0",
4
+ "description": "Open-source WHOOP CLI for humans and agents",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "whoop": "dist/index.js"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "dev": "tsx src/index.ts --help",
16
+ "typecheck": "tsc -p tsconfig.json --noEmit",
17
+ "test": "tsx --test test/**/*.test.ts",
18
+ "prepare": "npm run build",
19
+ "prepack": "npm run build"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "openclaw-skill",
24
+ "scripts/whoop-refresh-monitor.sh",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dependencies": {
29
+ "commander": "^13.1.0",
30
+ "zod": "^3.24.2"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.13.10",
34
+ "tsx": "^4.19.3",
35
+ "typescript": "^5.8.2"
36
+ }
37
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PROFILE="${WHOOP_PROFILE:-default}"
5
+ CLI="${WHOOP_CLI_BIN:-whoop}"
6
+
7
+ # 1) Refresh token state
8
+ $CLI auth refresh --profile "$PROFILE" --json >/tmp/whoop-auth-refresh.json
9
+
10
+ # 2) Lightweight health check
11
+ $CLI summary --profile "$PROFILE" --json >/tmp/whoop-summary.json
12
+
13
+ echo "whoop refresh monitor: ok"