@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,175 @@
1
+ import { ask } from '../util/prompt.js';
2
+ import { tryOpenBrowser } from '../util/open-browser.js';
3
+ import { buildAuthUrl, exchangeAuthCode, generateState, parseAuthInput } from '../auth/oauth.js';
4
+ import { getGlobalOptions, printData, printError } from './context.js';
5
+ import { loadProfile, saveProfile, clearProfileTokens } from '../store/profile-store.js';
6
+ import { tokenFromOAuth, refreshProfileToken } from '../auth/token-service.js';
7
+ import { configError, usageError } from '../http/errors.js';
8
+ const DEFAULT_SCOPES = [
9
+ 'read:recovery',
10
+ 'read:cycles',
11
+ 'read:workout',
12
+ 'read:sleep',
13
+ 'read:profile',
14
+ 'read:body_measurement',
15
+ 'offline',
16
+ ];
17
+ const splitScopes = (raw) => raw
18
+ ? raw
19
+ .split(/[\s,]+/)
20
+ .map((s) => s.trim())
21
+ .filter(Boolean)
22
+ : DEFAULT_SCOPES;
23
+ const resolveClientConfig = async (profileName, baseUrl, overrides) => {
24
+ const existing = await loadProfile(profileName);
25
+ const clientId = overrides.clientId ?? process.env.WHOOP_CLIENT_ID ?? existing?.clientId;
26
+ const clientSecret = overrides.clientSecret ?? process.env.WHOOP_CLIENT_SECRET ?? existing?.clientSecret;
27
+ const redirectUri = overrides.redirectUri ?? process.env.WHOOP_REDIRECT_URI ?? existing?.redirectUri;
28
+ if (!clientId || !clientSecret || !redirectUri) {
29
+ throw configError('Missing WHOOP OAuth client config. Provide --client-id --client-secret --redirect-uri (or env vars WHOOP_CLIENT_ID/WHOOP_CLIENT_SECRET/WHOOP_REDIRECT_URI).');
30
+ }
31
+ return {
32
+ profileName,
33
+ clientId,
34
+ clientSecret,
35
+ redirectUri,
36
+ baseUrl,
37
+ scopes: splitScopes(overrides.scopes ?? existing?.scopes?.join(' ')),
38
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
39
+ updatedAt: new Date().toISOString(),
40
+ tokens: existing?.tokens,
41
+ };
42
+ };
43
+ export const registerAuthCommands = (program) => {
44
+ const auth = program.command('auth').description('Authentication commands');
45
+ auth
46
+ .command('login')
47
+ .description('Run OAuth login flow and store tokens')
48
+ .option('--client-id <id>')
49
+ .option('--client-secret <secret>')
50
+ .option('--redirect-uri <url>')
51
+ .option('--scopes <scopes>', 'space/comma separated scopes')
52
+ .option('--code <code>', 'authorization code (skip prompt)')
53
+ .option('--state <state>', 'state override')
54
+ .option('--no-open', 'do not attempt to open browser')
55
+ .action(async function loginAction(opts) {
56
+ try {
57
+ const globals = getGlobalOptions(this);
58
+ const profile = await resolveClientConfig(globals.profile, globals.baseUrl, {
59
+ clientId: opts.clientId,
60
+ clientSecret: opts.clientSecret,
61
+ redirectUri: opts.redirectUri,
62
+ scopes: opts.scopes,
63
+ });
64
+ const state = opts.state ?? generateState();
65
+ const authUrl = buildAuthUrl({
66
+ clientId: profile.clientId,
67
+ clientSecret: profile.clientSecret,
68
+ redirectUri: profile.redirectUri,
69
+ baseUrl: profile.baseUrl,
70
+ }, profile.scopes, state);
71
+ let openAttempted = false;
72
+ if (opts.open !== false) {
73
+ openAttempted = tryOpenBrowser(authUrl);
74
+ }
75
+ let code = opts.code;
76
+ if (!code) {
77
+ if (!globals.json) {
78
+ console.log('Open this URL and authorize access:');
79
+ console.log(authUrl);
80
+ console.log(openAttempted ? '(attempted browser open)' : '(could not auto-open browser; copy URL manually)');
81
+ }
82
+ const input = await ask('Paste redirect URL (or authorization code): ');
83
+ const parsed = parseAuthInput(input);
84
+ code = parsed.code;
85
+ if (parsed.state && parsed.state !== state) {
86
+ throw usageError('OAuth state mismatch. Retry login flow for security.', {
87
+ expected: state,
88
+ received: parsed.state,
89
+ });
90
+ }
91
+ }
92
+ const tokenPayload = await exchangeAuthCode({
93
+ clientId: profile.clientId,
94
+ clientSecret: profile.clientSecret,
95
+ redirectUri: profile.redirectUri,
96
+ baseUrl: profile.baseUrl,
97
+ }, code);
98
+ profile.tokens = tokenFromOAuth(tokenPayload, profile.tokens?.refreshToken);
99
+ await saveProfile(globals.profile, profile);
100
+ printData(this, {
101
+ profile: globals.profile,
102
+ authenticated: true,
103
+ scopes: profile.scopes,
104
+ expiresAt: profile.tokens.expiresAt,
105
+ });
106
+ }
107
+ catch (err) {
108
+ printError(this, err);
109
+ }
110
+ });
111
+ auth
112
+ .command('status')
113
+ .description('Show current auth/token status')
114
+ .action(async function statusAction() {
115
+ try {
116
+ const globals = getGlobalOptions(this);
117
+ const profile = await loadProfile(globals.profile);
118
+ if (!profile?.tokens) {
119
+ printData(this, {
120
+ profile: globals.profile,
121
+ authenticated: false,
122
+ });
123
+ return;
124
+ }
125
+ const expiresAt = new Date(profile.tokens.expiresAt).getTime();
126
+ const remainingSeconds = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
127
+ printData(this, {
128
+ profile: globals.profile,
129
+ authenticated: true,
130
+ baseUrl: profile.baseUrl,
131
+ scopes: profile.scopes,
132
+ tokenType: profile.tokens.tokenType,
133
+ expiresAt: profile.tokens.expiresAt,
134
+ expiresInSeconds: remainingSeconds,
135
+ hasRefreshToken: Boolean(profile.tokens.refreshToken),
136
+ });
137
+ }
138
+ catch (err) {
139
+ printError(this, err);
140
+ }
141
+ });
142
+ auth
143
+ .command('refresh')
144
+ .description('Refresh access token using stored refresh token')
145
+ .action(async function refreshAction() {
146
+ try {
147
+ const globals = getGlobalOptions(this);
148
+ const profile = await refreshProfileToken(globals.profile);
149
+ printData(this, {
150
+ profile: globals.profile,
151
+ refreshed: true,
152
+ expiresAt: profile.tokens?.expiresAt,
153
+ });
154
+ }
155
+ catch (err) {
156
+ printError(this, err);
157
+ }
158
+ });
159
+ auth
160
+ .command('logout')
161
+ .description('Clear stored tokens for profile')
162
+ .action(async function logoutAction() {
163
+ try {
164
+ const globals = getGlobalOptions(this);
165
+ await clearProfileTokens(globals.profile);
166
+ printData(this, {
167
+ profile: globals.profile,
168
+ loggedOut: true,
169
+ });
170
+ }
171
+ catch (err) {
172
+ printError(this, err);
173
+ }
174
+ });
175
+ };
@@ -0,0 +1,87 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { WhoopApiClient } from '../http/client.js';
3
+ import { fetchRecoveries } from '../http/whoop-data.js';
4
+ import { getGlobalOptions, printData, printError } from './context.js';
5
+ import { behaviorLogPath } from '../util/config.js';
6
+ import { avg, round } from '../util/metrics.js';
7
+ import { featureUnavailableError, usageError } from '../http/errors.js';
8
+ const readBehaviorLog = async (path) => {
9
+ const content = await readFile(path, 'utf8');
10
+ const rows = [];
11
+ for (const line of content.split('\n')) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed)
14
+ continue;
15
+ rows.push(JSON.parse(trimmed));
16
+ }
17
+ return rows;
18
+ };
19
+ export const registerBehaviorCommands = (program) => {
20
+ const behavior = program.command('behavior').description('Behavior impact and experiments');
21
+ behavior
22
+ .command('impacts')
23
+ .description('Estimate behavior impacts from local behavior log + WHOOP recoveries')
24
+ .option('--file <path>', 'behavior log jsonl path', behaviorLogPath())
25
+ .option('--days <n>', 'lookback days', '30')
26
+ .action(async function impactsAction(opts) {
27
+ try {
28
+ const globals = getGlobalOptions(this);
29
+ const file = opts.file;
30
+ const days = Number(opts.days ?? 30);
31
+ if (Number.isNaN(days) || days <= 0)
32
+ throw usageError('days must be > 0');
33
+ const rows = await readBehaviorLog(file).catch(() => {
34
+ throw featureUnavailableError('Behavior impacts require a local behavior log JSONL file. WHOOP public API does not currently expose Journal behavior impacts directly.', { file });
35
+ });
36
+ const client = new WhoopApiClient(globals.profile);
37
+ const recoveries = await fetchRecoveries(client, {
38
+ ...{ start: undefined, end: undefined },
39
+ limit: 25,
40
+ all: true,
41
+ timeoutMs: globals.timeoutMs,
42
+ });
43
+ const recoveryByDate = new Map();
44
+ for (const r of recoveries) {
45
+ const created = r.created_at?.slice(0, 10);
46
+ const score = r.score?.recovery_score;
47
+ if (created && typeof score === 'number') {
48
+ recoveryByDate.set(created, score);
49
+ }
50
+ }
51
+ const behaviorScores = new Map();
52
+ for (const row of rows) {
53
+ const score = recoveryByDate.get(row.date);
54
+ if (typeof score !== 'number')
55
+ continue;
56
+ for (const [behaviorName, active] of Object.entries(row.behaviors)) {
57
+ const bucket = behaviorScores.get(behaviorName) ?? { yes: [], no: [] };
58
+ (active ? bucket.yes : bucket.no).push(score);
59
+ behaviorScores.set(behaviorName, bucket);
60
+ }
61
+ }
62
+ const impacts = Array.from(behaviorScores.entries())
63
+ .map(([behaviorName, bucket]) => {
64
+ const yesAvg = avg(bucket.yes);
65
+ const noAvg = avg(bucket.no);
66
+ const delta = yesAvg !== null && noAvg !== null ? yesAvg - noAvg : null;
67
+ return {
68
+ behavior: behaviorName,
69
+ yesCount: bucket.yes.length,
70
+ noCount: bucket.no.length,
71
+ yesRecoveryAvg: round(yesAvg, 1),
72
+ noRecoveryAvg: round(noAvg, 1),
73
+ deltaRecovery: round(delta, 1),
74
+ };
75
+ })
76
+ .sort((a, b) => (b.deltaRecovery ?? -999) - (a.deltaRecovery ?? -999));
77
+ printData(this, {
78
+ source: file,
79
+ records: rows.length,
80
+ impacts,
81
+ });
82
+ }
83
+ catch (err) {
84
+ printError(this, err);
85
+ }
86
+ });
87
+ };
@@ -0,0 +1,39 @@
1
+ import { DEFAULT_BASE_URL } from '../util/config.js';
2
+ import { fromError, ok, stringifyEnvelope } from '../output/envelope.js';
3
+ import { normalizeError } from '../http/errors.js';
4
+ export const getGlobalOptions = (command) => {
5
+ const opts = command.optsWithGlobals();
6
+ return {
7
+ json: Boolean(opts.json),
8
+ pretty: Boolean(opts.pretty),
9
+ profile: opts.profile ?? 'default',
10
+ baseUrl: opts.baseUrl ?? DEFAULT_BASE_URL,
11
+ timeoutMs: Number(opts.timeoutMs ?? '10000'),
12
+ };
13
+ };
14
+ export const printData = (command, data) => {
15
+ const globals = getGlobalOptions(command);
16
+ if (globals.json) {
17
+ console.log(stringifyEnvelope(ok(data), globals.pretty));
18
+ return;
19
+ }
20
+ if (typeof data === 'string') {
21
+ console.log(data);
22
+ return;
23
+ }
24
+ console.log(JSON.stringify(data, null, 2));
25
+ };
26
+ export const printError = (command, err) => {
27
+ const globals = getGlobalOptions(command);
28
+ const normalized = normalizeError(err);
29
+ if (globals.json) {
30
+ console.log(stringifyEnvelope(fromError(normalized), globals.pretty));
31
+ }
32
+ else {
33
+ console.error(`${normalized.code}: ${normalized.message}`);
34
+ if (normalized.details) {
35
+ console.error(JSON.stringify(normalized.details, null, 2));
36
+ }
37
+ }
38
+ process.exit(normalized.exitCode);
39
+ };
@@ -0,0 +1,58 @@
1
+ import { WhoopApiClient } from '../http/client.js';
2
+ import { fetchCycles } from '../http/whoop-data.js';
3
+ import { getGlobalOptions, printData, printError } from './context.js';
4
+ import { parseDateRange, parseMaybeNumber } from '../util/time.js';
5
+ export const registerCycleCommands = (program) => {
6
+ const cycle = program.command('cycle').description('Cycle commands');
7
+ cycle
8
+ .command('latest')
9
+ .description('Fetch latest physiological cycle')
10
+ .action(async function latestAction() {
11
+ try {
12
+ const globals = getGlobalOptions(this);
13
+ const client = new WhoopApiClient(globals.profile);
14
+ const records = await fetchCycles(client, {
15
+ limit: 1,
16
+ timeoutMs: globals.timeoutMs,
17
+ });
18
+ printData(this, {
19
+ latest: records[0] ?? null,
20
+ });
21
+ }
22
+ catch (err) {
23
+ printError(this, err);
24
+ }
25
+ });
26
+ cycle
27
+ .command('list')
28
+ .description('List cycles')
29
+ .option('--start <YYYY-MM-DD>')
30
+ .option('--end <YYYY-MM-DD>')
31
+ .option('--days <n>', 'lookback days if start/end not provided')
32
+ .option('--limit <n>', 'page size', '25')
33
+ .option('--all', 'follow pagination and fetch all pages')
34
+ .action(async function listAction(opts) {
35
+ try {
36
+ const globals = getGlobalOptions(this);
37
+ const client = new WhoopApiClient(globals.profile);
38
+ const range = parseDateRange({
39
+ start: opts.start,
40
+ end: opts.end,
41
+ days: parseMaybeNumber(opts.days),
42
+ });
43
+ const records = await fetchCycles(client, {
44
+ ...range,
45
+ limit: parseMaybeNumber(opts.limit),
46
+ all: Boolean(opts.all),
47
+ timeoutMs: globals.timeoutMs,
48
+ });
49
+ printData(this, {
50
+ count: records.length,
51
+ records,
52
+ });
53
+ }
54
+ catch (err) {
55
+ printError(this, err);
56
+ }
57
+ });
58
+ };
@@ -0,0 +1,122 @@
1
+ import { readJsonFile, writeJsonFileSecure } from '../util/fs.js';
2
+ import { experimentsPath } from '../util/config.js';
3
+ import { assertIsoDate, daysAgoIso } from '../util/time.js';
4
+ import { getGlobalOptions, printData, printError } from './context.js';
5
+ import { usageError } from '../http/errors.js';
6
+ import { WhoopApiClient } from '../http/client.js';
7
+ import { fetchRecoveries } from '../http/whoop-data.js';
8
+ import { avg, round } from '../util/metrics.js';
9
+ const loadStore = async () => {
10
+ const store = await readJsonFile(experimentsPath());
11
+ return store ?? { experiments: [] };
12
+ };
13
+ const saveStore = async (store) => {
14
+ await writeJsonFileSecure(experimentsPath(), store);
15
+ };
16
+ const makeId = () => Math.random().toString(36).slice(2, 10);
17
+ export const registerExperimentCommands = (program) => {
18
+ const experiment = program.command('experiment').description('Behavior experiment tracking');
19
+ experiment
20
+ .command('start')
21
+ .description('Start a behavior experiment')
22
+ .requiredOption('--name <name>')
23
+ .requiredOption('--behavior <behavior>')
24
+ .option('--start-date <YYYY-MM-DD>', 'default: today UTC')
25
+ .option('--end-date <YYYY-MM-DD>')
26
+ .action(async function startAction(opts) {
27
+ try {
28
+ const startDate = opts.startDate ? assertIsoDate(opts.startDate, 'start-date') : daysAgoIso(0);
29
+ const endDate = opts.endDate ? assertIsoDate(opts.endDate, 'end-date') : undefined;
30
+ const store = await loadStore();
31
+ const item = {
32
+ id: makeId(),
33
+ name: String(opts.name),
34
+ behavior: String(opts.behavior),
35
+ startDate,
36
+ endDate,
37
+ createdAt: new Date().toISOString(),
38
+ };
39
+ store.experiments.unshift(item);
40
+ await saveStore(store);
41
+ printData(this, item);
42
+ }
43
+ catch (err) {
44
+ printError(this, err);
45
+ }
46
+ });
47
+ experiment
48
+ .command('list')
49
+ .description('List saved experiments')
50
+ .action(async function listAction() {
51
+ try {
52
+ const store = await loadStore();
53
+ printData(this, { count: store.experiments.length, experiments: store.experiments });
54
+ }
55
+ catch (err) {
56
+ printError(this, err);
57
+ }
58
+ });
59
+ experiment
60
+ .command('report')
61
+ .description('Generate baseline-vs-experiment recovery report')
62
+ .requiredOption('--id <experimentId>')
63
+ .option('--baseline-days <n>', 'default 14', '14')
64
+ .action(async function reportAction(opts) {
65
+ try {
66
+ const globals = getGlobalOptions(this);
67
+ const baselineDays = Number(opts.baselineDays ?? 14);
68
+ if (Number.isNaN(baselineDays) || baselineDays <= 0) {
69
+ throw usageError('baseline-days must be > 0');
70
+ }
71
+ const store = await loadStore();
72
+ const exp = store.experiments.find((x) => x.id === opts.id);
73
+ if (!exp) {
74
+ throw usageError('Experiment not found', { id: opts.id });
75
+ }
76
+ const client = new WhoopApiClient(globals.profile);
77
+ const recoveries = await fetchRecoveries(client, {
78
+ limit: 25,
79
+ all: true,
80
+ timeoutMs: globals.timeoutMs,
81
+ });
82
+ const byDate = recoveries
83
+ .map((r) => ({ date: r.created_at?.slice(0, 10), score: r.score?.recovery_score }))
84
+ .filter((r) => Boolean(r.date) && typeof r.score === 'number');
85
+ const expStart = new Date(`${exp.startDate}T00:00:00Z`).getTime();
86
+ const expEnd = exp.endDate ? new Date(`${exp.endDate}T23:59:59Z`).getTime() : Date.now();
87
+ const baselineStart = expStart - baselineDays * 24 * 60 * 60 * 1000;
88
+ const baselineEnd = expStart - 1;
89
+ const baselineScores = byDate
90
+ .filter((r) => {
91
+ const t = new Date(`${r.date}T12:00:00Z`).getTime();
92
+ return t >= baselineStart && t <= baselineEnd;
93
+ })
94
+ .map((r) => r.score);
95
+ const experimentScores = byDate
96
+ .filter((r) => {
97
+ const t = new Date(`${r.date}T12:00:00Z`).getTime();
98
+ return t >= expStart && t <= expEnd;
99
+ })
100
+ .map((r) => r.score);
101
+ const baselineAvg = avg(baselineScores);
102
+ const experimentAvg = avg(experimentScores);
103
+ const delta = baselineAvg !== null && experimentAvg !== null ? experimentAvg - baselineAvg : null;
104
+ printData(this, {
105
+ experiment: exp,
106
+ baselineDays,
107
+ baseline: {
108
+ samples: baselineScores.length,
109
+ recoveryAvg: round(baselineAvg, 1),
110
+ },
111
+ period: {
112
+ samples: experimentScores.length,
113
+ recoveryAvg: round(experimentAvg, 1),
114
+ },
115
+ deltaRecovery: round(delta, 1),
116
+ });
117
+ }
118
+ catch (err) {
119
+ printError(this, err);
120
+ }
121
+ });
122
+ };
@@ -0,0 +1,136 @@
1
+ import { WhoopApiClient } from '../http/client.js';
2
+ import { fetchRecoveries, fetchSleeps } from '../http/whoop-data.js';
3
+ import { avg, round } from '../util/metrics.js';
4
+ import { parseDateRange, parseMaybeNumber } from '../util/time.js';
5
+ import { getGlobalOptions, printData, printError } from './context.js';
6
+ export const registerHealthCommands = (program) => {
7
+ const health = program.command('health').description('Health flags and trend views');
8
+ health
9
+ .command('flags')
10
+ .description('Detect likely recovery stress flags from recent baseline deltas')
11
+ .option('--days <n>', 'lookback days', '14')
12
+ .action(async function flagsAction(opts) {
13
+ try {
14
+ const globals = getGlobalOptions(this);
15
+ const client = new WhoopApiClient(globals.profile);
16
+ const days = parseMaybeNumber(opts.days) ?? 14;
17
+ const [recoveries, sleeps] = await Promise.all([
18
+ fetchRecoveries(client, {
19
+ ...parseDateRange({ days }),
20
+ limit: 25,
21
+ all: true,
22
+ timeoutMs: globals.timeoutMs,
23
+ }),
24
+ fetchSleeps(client, {
25
+ ...parseDateRange({ days }),
26
+ limit: 25,
27
+ all: true,
28
+ timeoutMs: globals.timeoutMs,
29
+ }),
30
+ ]);
31
+ const latestRecovery = recoveries[0];
32
+ const baseline = recoveries.slice(1);
33
+ const baselineHrv = avg(baseline.map((r) => r.score?.hrv_rmssd_milli));
34
+ const baselineRhr = avg(baseline.map((r) => r.score?.resting_heart_rate));
35
+ const baselineRecovery = avg(baseline.map((r) => r.score?.recovery_score));
36
+ const flags = [];
37
+ if (latestRecovery?.score?.recovery_score !== undefined && latestRecovery.score.recovery_score < 34) {
38
+ flags.push({
39
+ code: 'LOW_RECOVERY',
40
+ message: 'Recovery score is in red zone (<34).',
41
+ severity: 'high',
42
+ });
43
+ }
44
+ if (baselineHrv &&
45
+ latestRecovery?.score?.hrv_rmssd_milli !== undefined &&
46
+ latestRecovery.score.hrv_rmssd_milli < baselineHrv * 0.8) {
47
+ flags.push({
48
+ code: 'HRV_DROP',
49
+ message: 'HRV is >20% below trailing baseline.',
50
+ severity: 'med',
51
+ });
52
+ }
53
+ if (baselineRhr !== null &&
54
+ baselineRhr !== undefined &&
55
+ latestRecovery?.score?.resting_heart_rate !== undefined &&
56
+ latestRecovery.score.resting_heart_rate > baselineRhr + 5) {
57
+ flags.push({
58
+ code: 'RHR_ELEVATION',
59
+ message: 'Resting heart rate is elevated >5 bpm over baseline.',
60
+ severity: 'med',
61
+ });
62
+ }
63
+ const latestSleep = sleeps[0];
64
+ if (latestSleep?.score?.sleep_performance_percentage !== undefined &&
65
+ latestSleep.score.sleep_performance_percentage < 70) {
66
+ flags.push({
67
+ code: 'LOW_SLEEP_PERFORMANCE',
68
+ message: 'Sleep performance is below 70%.',
69
+ severity: 'med',
70
+ });
71
+ }
72
+ printData(this, {
73
+ windowDays: days,
74
+ latest: {
75
+ recoveryScore: latestRecovery?.score?.recovery_score ?? null,
76
+ hrv: latestRecovery?.score?.hrv_rmssd_milli ?? null,
77
+ rhr: latestRecovery?.score?.resting_heart_rate ?? null,
78
+ sleepPerformance: latestSleep?.score?.sleep_performance_percentage ?? null,
79
+ },
80
+ baseline: {
81
+ recoveryScoreAvg: round(baselineRecovery, 1),
82
+ hrvAvg: round(baselineHrv, 2),
83
+ rhrAvg: round(baselineRhr, 1),
84
+ },
85
+ flags,
86
+ });
87
+ }
88
+ catch (err) {
89
+ printError(this, err);
90
+ }
91
+ });
92
+ health
93
+ .command('trend')
94
+ .description('Trend summary for recovery and sleep metrics')
95
+ .option('--days <n>', 'lookback days', '30')
96
+ .action(async function trendAction(opts) {
97
+ try {
98
+ const globals = getGlobalOptions(this);
99
+ const client = new WhoopApiClient(globals.profile);
100
+ const days = parseMaybeNumber(opts.days) ?? 30;
101
+ const range = parseDateRange({ days });
102
+ const [recoveries, sleeps] = await Promise.all([
103
+ fetchRecoveries(client, {
104
+ ...range,
105
+ limit: 25,
106
+ all: true,
107
+ timeoutMs: globals.timeoutMs,
108
+ }),
109
+ fetchSleeps(client, {
110
+ ...range,
111
+ limit: 25,
112
+ all: true,
113
+ timeoutMs: globals.timeoutMs,
114
+ }),
115
+ ]);
116
+ printData(this, {
117
+ windowDays: days,
118
+ recovery: {
119
+ records: recoveries.length,
120
+ recoveryScoreAvg: round(avg(recoveries.map((r) => r.score?.recovery_score)), 1),
121
+ hrvAvg: round(avg(recoveries.map((r) => r.score?.hrv_rmssd_milli)), 2),
122
+ rhrAvg: round(avg(recoveries.map((r) => r.score?.resting_heart_rate)), 1),
123
+ },
124
+ sleep: {
125
+ records: sleeps.length,
126
+ sleepPerformanceAvg: round(avg(sleeps.map((s) => s.score?.sleep_performance_percentage)), 1),
127
+ sleepConsistencyAvg: round(avg(sleeps.map((s) => s.score?.sleep_consistency_percentage)), 1),
128
+ sleepEfficiencyAvg: round(avg(sleeps.map((s) => s.score?.sleep_efficiency_percentage)), 1),
129
+ },
130
+ });
131
+ }
132
+ catch (err) {
133
+ printError(this, err);
134
+ }
135
+ });
136
+ };
@@ -0,0 +1,49 @@
1
+ import { copyFile, mkdir, access } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { getGlobalOptions, printData, printError } from './context.js';
6
+ import { usageError } from '../http/errors.js';
7
+ const defaultOpenclawHome = () => join(homedir(), '.openclaw');
8
+ const canRead = async (path) => {
9
+ try {
10
+ await access(path, constants.R_OK);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ };
17
+ export const registerOpenClawCommands = (program) => {
18
+ const openclaw = program.command('openclaw').description('OpenClaw helper commands');
19
+ openclaw
20
+ .command('install-skill')
21
+ .description('Install bundled whoop-cli OpenClaw skill into ~/.openclaw/workspace/skills/whoop-cli')
22
+ .option('--openclaw-home <path>', 'override OpenClaw home directory', defaultOpenclawHome())
23
+ .option('--force', 'overwrite existing SKILL.md if present', false)
24
+ .action(async function installSkillAction(opts) {
25
+ try {
26
+ getGlobalOptions(this);
27
+ const openclawHome = String(opts.openclawHome ?? defaultOpenclawHome());
28
+ const targetDir = join(openclawHome, 'workspace', 'skills', 'whoop-cli');
29
+ const targetFile = join(targetDir, 'SKILL.md');
30
+ const sourceFile = new URL('../../openclaw-skill/SKILL.md', import.meta.url);
31
+ const exists = await canRead(targetFile);
32
+ if (exists && !opts.force) {
33
+ throw usageError('Target skill file already exists. Re-run with --force to overwrite.', {
34
+ targetFile,
35
+ });
36
+ }
37
+ await mkdir(targetDir, { recursive: true });
38
+ await copyFile(sourceFile, targetFile);
39
+ printData(this, {
40
+ installed: true,
41
+ targetFile,
42
+ force: Boolean(opts.force),
43
+ });
44
+ }
45
+ catch (err) {
46
+ printError(this, err);
47
+ }
48
+ });
49
+ };