@trainheroic-unofficial/cli 0.2.0 → 0.4.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/dist/cli.mjs
CHANGED
|
@@ -1,14 +1,175 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import process from "node:process";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
7
|
import { z } from "zod";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
8
|
//#region ../dto/src/common.ts
|
|
9
9
|
/** An entity id as it arrives over the wire — a number or a numeric string. */
|
|
10
10
|
const idSchema = z.union([z.string(), z.number()]);
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* An id as a tool argument: a number, or a string of digits. Unlike idSchema (which
|
|
13
|
+
* tolerates whatever the API sends back), this rejects non-numeric strings up front so
|
|
14
|
+
* a bad argument fails validation instead of becoming NaN in a URL or a query.
|
|
15
|
+
*/
|
|
16
|
+
const idArgSchema = z.union([z.number().int(), z.string().regex(/^\d+$/u)]);
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region ../dto/src/athlete.ts
|
|
19
|
+
const intLike$1 = z.union([z.number(), z.string()]);
|
|
20
|
+
const intLikeOrNull$1 = z.union([
|
|
21
|
+
z.number(),
|
|
22
|
+
z.string(),
|
|
23
|
+
z.null()
|
|
24
|
+
]);
|
|
25
|
+
const numLikeOrNull = z.union([
|
|
26
|
+
z.number(),
|
|
27
|
+
z.string(),
|
|
28
|
+
z.null()
|
|
29
|
+
]);
|
|
30
|
+
z.looseObject({
|
|
31
|
+
id: intLike$1,
|
|
32
|
+
roles: z.array(z.string()).optional(),
|
|
33
|
+
org_id: intLikeOrNull$1.optional()
|
|
34
|
+
});
|
|
35
|
+
z.looseObject({
|
|
36
|
+
reps_sum: z.number().optional(),
|
|
37
|
+
volume_sum: z.number().optional(),
|
|
38
|
+
sessions_count: z.number().optional(),
|
|
39
|
+
first_logged_date: z.string().optional(),
|
|
40
|
+
last_logged_date: z.string().optional(),
|
|
41
|
+
duration_hours: z.number().optional()
|
|
42
|
+
});
|
|
43
|
+
z.looseObject({
|
|
44
|
+
id: intLike$1,
|
|
45
|
+
email: z.string().optional(),
|
|
46
|
+
name_first: z.string().optional(),
|
|
47
|
+
name_last: z.string().optional(),
|
|
48
|
+
username: z.string().optional(),
|
|
49
|
+
gender: z.string().optional(),
|
|
50
|
+
date_of_birth: z.string().optional(),
|
|
51
|
+
use_metric: z.boolean().optional()
|
|
52
|
+
});
|
|
53
|
+
z.looseObject({ id: intLike$1 });
|
|
54
|
+
/** One item of `/2.0/athlete/workingMax` — the athlete's working max for an exercise. */
|
|
55
|
+
const athleteWorkingMaxSchema = z.looseObject({
|
|
56
|
+
exercise_id: intLike$1,
|
|
57
|
+
title: z.string().optional(),
|
|
58
|
+
param_type: intLikeOrNull$1.optional(),
|
|
59
|
+
value: numLikeOrNull.optional(),
|
|
60
|
+
type_suffix: z.string().optional(),
|
|
61
|
+
working_max_id: intLikeOrNull$1.optional()
|
|
62
|
+
});
|
|
63
|
+
z.array(athleteWorkingMaxSchema);
|
|
64
|
+
/** One item of `/v5/users/exercises/history` — an exercise the athlete has logged. */
|
|
65
|
+
const exerciseHistoryListItemSchema = z.looseObject({
|
|
66
|
+
id: intLike$1,
|
|
67
|
+
title: z.string(),
|
|
68
|
+
isCircuit: z.boolean().optional(),
|
|
69
|
+
prescription: z.string().optional(),
|
|
70
|
+
param1Type: intLikeOrNull$1.optional(),
|
|
71
|
+
param2Type: intLikeOrNull$1.optional()
|
|
72
|
+
});
|
|
73
|
+
z.array(exerciseHistoryListItemSchema);
|
|
74
|
+
/** A single completed set inside a history entry (`/v5/exercises/{id}/history`). */
|
|
75
|
+
const historySetSchema = z.looseObject({
|
|
76
|
+
setNumber: z.number(),
|
|
77
|
+
formattedValue: z.string().optional(),
|
|
78
|
+
rawValue1: numLikeOrNull.optional(),
|
|
79
|
+
rawValue2: numLikeOrNull.optional(),
|
|
80
|
+
savedWorkoutSetExerciseId: intLike$1.optional()
|
|
81
|
+
});
|
|
82
|
+
/** A best rep-max derived for a history entry. */
|
|
83
|
+
const repMaxSchema = z.looseObject({
|
|
84
|
+
reps: z.number(),
|
|
85
|
+
weight: z.number()
|
|
86
|
+
});
|
|
87
|
+
/** One performed session of an exercise (`/v5/exercises/{id}/history` → `history[]`). */
|
|
88
|
+
const historyEntrySchema = z.looseObject({
|
|
89
|
+
dateCompleted: z.string(),
|
|
90
|
+
notes: z.string().nullable().optional(),
|
|
91
|
+
isLift: z.boolean().optional(),
|
|
92
|
+
param1Type: intLikeOrNull$1.optional(),
|
|
93
|
+
param2Type: intLikeOrNull$1.optional(),
|
|
94
|
+
savedWorkoutSetExerciseId: intLike$1.optional(),
|
|
95
|
+
teamId: intLikeOrNull$1.optional(),
|
|
96
|
+
programWorkoutId: intLikeOrNull$1.optional(),
|
|
97
|
+
abr: z.string().optional(),
|
|
98
|
+
bestEstimated1RM: z.number().optional(),
|
|
99
|
+
repMaxes: z.array(repMaxSchema).optional(),
|
|
100
|
+
sets: z.array(historySetSchema).optional()
|
|
101
|
+
});
|
|
102
|
+
/** A lifetime PR row from `/v5/exercises/{id}/history` → `liftPRs[]`. */
|
|
103
|
+
const liftPRSchema = z.looseObject({
|
|
104
|
+
weight: z.number().optional(),
|
|
105
|
+
savedWorkoutSetExerciseId: intLike$1.optional(),
|
|
106
|
+
setNumber: z.number().optional(),
|
|
107
|
+
dateCompleted: z.string().optional(),
|
|
108
|
+
reps: z.number().optional(),
|
|
109
|
+
units: z.string().optional(),
|
|
110
|
+
isMetric: z.boolean().optional(),
|
|
111
|
+
description: z.string().optional()
|
|
112
|
+
});
|
|
113
|
+
z.looseObject({
|
|
114
|
+
liftPRs: z.array(liftPRSchema).optional(),
|
|
115
|
+
singleParamPRs: z.array(z.unknown()).optional(),
|
|
116
|
+
history: z.array(historyEntrySchema).optional()
|
|
117
|
+
});
|
|
118
|
+
/** One item of `/v5/exercises/{id}/personalRecords` — a standards-filtered PR. */
|
|
119
|
+
const personalRecordSchema = z.looseObject({
|
|
120
|
+
id: intLike$1.optional(),
|
|
121
|
+
savedWorkoutSetExerciseId: intLike$1.optional(),
|
|
122
|
+
setNumber: z.number().optional(),
|
|
123
|
+
reps: z.number().optional(),
|
|
124
|
+
weight: z.number().optional(),
|
|
125
|
+
scaledWeight: z.number().optional(),
|
|
126
|
+
units: z.string().optional(),
|
|
127
|
+
isMetric: z.boolean().optional()
|
|
128
|
+
});
|
|
129
|
+
z.array(personalRecordSchema);
|
|
130
|
+
z.looseObject({
|
|
131
|
+
isLift: z.boolean().optional(),
|
|
132
|
+
lastPerformance: z.unknown().optional(),
|
|
133
|
+
personalRecord: z.unknown().optional()
|
|
134
|
+
});
|
|
135
|
+
/**
|
|
136
|
+
* One item of `/3.0/athlete/programworkout/range` — a scheduled/completed workout. The deep
|
|
137
|
+
* `summarizedSavedWorkout` tree is left loose: the presenter in `js` flattens it, so dto only
|
|
138
|
+
* pins the top-level fields the warehouse and presenter key off.
|
|
139
|
+
*/
|
|
140
|
+
const programWorkoutSchema = z.looseObject({
|
|
141
|
+
id: intLike$1,
|
|
142
|
+
date: z.string().optional(),
|
|
143
|
+
workout_title: z.string().optional(),
|
|
144
|
+
program_id: intLikeOrNull$1.optional(),
|
|
145
|
+
program_title: z.string().optional(),
|
|
146
|
+
team_id: intLikeOrNull$1.optional(),
|
|
147
|
+
team_title: z.string().optional(),
|
|
148
|
+
summarizedSavedWorkout: z.unknown().optional()
|
|
149
|
+
});
|
|
150
|
+
z.array(programWorkoutSchema);
|
|
151
|
+
/** A `YYYY-MM-DD` date argument. The single definition reused across athlete tool inputs. */
|
|
152
|
+
const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "expected YYYY-MM-DD");
|
|
153
|
+
z.object({
|
|
154
|
+
startDate: dateString,
|
|
155
|
+
endDate: dateString
|
|
156
|
+
});
|
|
157
|
+
/**
|
|
158
|
+
* Args for the set-logging write. `date` (the workout's day) locates the saved
|
|
159
|
+
* workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
|
|
160
|
+
* gives, per exercise in it, the entered value of each set (param 1 / param 2 by entry slot).
|
|
161
|
+
*/
|
|
162
|
+
const logSetArgsSchema = z.object({
|
|
163
|
+
date: dateString,
|
|
164
|
+
savedWorkoutSetId: idArgSchema,
|
|
165
|
+
results: z.array(z.object({
|
|
166
|
+
savedWorkoutSetExerciseId: idArgSchema,
|
|
167
|
+
sets: z.array(z.object({
|
|
168
|
+
param1: z.union([z.number(), z.string()]).optional(),
|
|
169
|
+
param2: z.union([z.number(), z.string()]).optional()
|
|
170
|
+
})).min(1)
|
|
171
|
+
})).min(1)
|
|
172
|
+
});
|
|
12
173
|
//#endregion
|
|
13
174
|
//#region ../dto/src/exercise.ts
|
|
14
175
|
/** Body for creating a custom exercise; extra fields the API accepts are preserved. */
|
|
@@ -270,12 +431,31 @@ function unitLabel(paramType) {
|
|
|
270
431
|
if (t === null) return null;
|
|
271
432
|
return PARAM_UNIT[t] ?? null;
|
|
272
433
|
}
|
|
273
|
-
/**
|
|
434
|
+
/**
|
|
435
|
+
* Fixed measurement units for an exercise, ordered by entry slot (param 1, then param 2).
|
|
436
|
+
* Positional, not semantic: some exercises reverse the slots, so the array is not labelled
|
|
437
|
+
* by role. A null entry is an unset slot.
|
|
438
|
+
*/
|
|
439
|
+
function exerciseUnits(param1, param2) {
|
|
440
|
+
return [unitLabel(param1), unitLabel(param2)];
|
|
441
|
+
}
|
|
442
|
+
/** Present a row for display: drop the raw param-type codes, surface units positionally. */
|
|
274
443
|
function withUnits(row) {
|
|
444
|
+
const { param_1_type, param_2_type, ...rest } = row;
|
|
445
|
+
return {
|
|
446
|
+
...rest,
|
|
447
|
+
units: exerciseUnits(param_1_type, param_2_type)
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Present a full raw exercise object for display: drop the raw param-type codes and add the
|
|
452
|
+
* positional `units` array. Keeps every other field of the raw object intact.
|
|
453
|
+
*/
|
|
454
|
+
function presentExercise$1(raw) {
|
|
455
|
+
const { param_1_type, param_2_type, ...rest } = raw;
|
|
275
456
|
return {
|
|
276
|
-
...
|
|
277
|
-
|
|
278
|
-
param_2_unit: unitLabel(row.param_2_type)
|
|
457
|
+
...rest,
|
|
458
|
+
units: exerciseUnits(param_1_type, param_2_type)
|
|
279
459
|
};
|
|
280
460
|
}
|
|
281
461
|
function buildSearchText(title) {
|
|
@@ -337,13 +517,314 @@ function rankSearch(rows, query, limit) {
|
|
|
337
517
|
if (title.startsWith(q)) score += 100;
|
|
338
518
|
for (const tok of tokens) if (title.includes(tok)) score += 10;
|
|
339
519
|
score -= title.length * .05;
|
|
340
|
-
if (row.can_edit === 0) score += 1;
|
|
520
|
+
if ((row.can_edit ?? 0) === 0) score += 1;
|
|
341
521
|
return {
|
|
342
522
|
row,
|
|
343
523
|
score
|
|
344
524
|
};
|
|
345
525
|
}).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
|
|
346
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* Map over items with a bounded number of concurrent workers. Used to fan out upstream
|
|
529
|
+
* fetches (per-exercise history, the CLI export) without bursting the host all at once or,
|
|
530
|
+
* on workerd, blowing the subrequest budget.
|
|
531
|
+
*/
|
|
532
|
+
async function mapPool(items, limit, fn) {
|
|
533
|
+
const out = Array.from({ length: items.length });
|
|
534
|
+
let next = 0;
|
|
535
|
+
const worker = async () => {
|
|
536
|
+
while (next < items.length) {
|
|
537
|
+
const i = next;
|
|
538
|
+
next += 1;
|
|
539
|
+
out[i] = await fn(items[i], i);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region ../js/src/athlete.ts
|
|
547
|
+
async function getJson(client, path, label) {
|
|
548
|
+
const res = await client.request("GET", path);
|
|
549
|
+
if (!res.ok) throw new Error(`${label} failed (HTTP ${res.status}).`);
|
|
550
|
+
return res.data;
|
|
551
|
+
}
|
|
552
|
+
async function getArray(client, path, label) {
|
|
553
|
+
const res = await client.request("GET", path);
|
|
554
|
+
if (!res.ok || !Array.isArray(res.data)) throw new Error(`${label} failed (HTTP ${res.status}).`);
|
|
555
|
+
return res.data;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The logged-in account's numeric user id (the athlete warehouse's tenant key, and a required
|
|
559
|
+
* query arg for several athlete endpoints). Works from any session — coach or athlete — and
|
|
560
|
+
* even from a cached session that never went through login.
|
|
561
|
+
*/
|
|
562
|
+
async function resolveAthleteUserId(client) {
|
|
563
|
+
const res = await client.request("GET", "/user/simple");
|
|
564
|
+
const id = isRecord(res.data) ? coerceInt(res.data.id) : null;
|
|
565
|
+
if (!res.ok || id === null || id <= 0) throw new Error("Could not resolve athlete user id from /user/simple.");
|
|
566
|
+
return id;
|
|
567
|
+
}
|
|
568
|
+
/** Lifetime training totals. `use_metric` is required by the API (omitting it 400s). */
|
|
569
|
+
function fetchAthleteProfileSummary(client, userId, useMetric = false) {
|
|
570
|
+
return getJson(client, `/v5/athleteProfile/summary?user_id=${userId}&use_metric=${useMetric ? 1 : 0}`, "athlete profile summary");
|
|
571
|
+
}
|
|
572
|
+
function fetchAthleteUser(client, userId) {
|
|
573
|
+
return getJson(client, `/v5/users/${userId}`, "athlete user");
|
|
574
|
+
}
|
|
575
|
+
function fetchAthletePrefs(client) {
|
|
576
|
+
return getJson(client, "/1.0/athlete/prefs", "athlete prefs");
|
|
577
|
+
}
|
|
578
|
+
function fetchWorkingMaxes(client) {
|
|
579
|
+
return getArray(client, "/2.0/athlete/workingMax", "athlete working maxes");
|
|
580
|
+
}
|
|
581
|
+
function fetchExerciseHistoryList(client) {
|
|
582
|
+
return getArray(client, "/v5/users/exercises/history", "athlete exercise history list");
|
|
583
|
+
}
|
|
584
|
+
/** Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). */
|
|
585
|
+
async function searchExerciseHistory(client, query, limit = 20) {
|
|
586
|
+
return rankSearch(await fetchExerciseHistoryList(client), query, limit);
|
|
587
|
+
}
|
|
588
|
+
function fetchExerciseHistoryDetail(client, exerciseId, userId) {
|
|
589
|
+
return getJson(client, `/v5/exercises/${exerciseId}/history?userId=${userId}`, "athlete exercise history");
|
|
590
|
+
}
|
|
591
|
+
function fetchPersonalRecords(client, exerciseId) {
|
|
592
|
+
return getArray(client, `/v5/exercises/${exerciseId}/personalRecords`, "athlete personal records");
|
|
593
|
+
}
|
|
594
|
+
/** Last performance + PR for an exercise. `date` (YYYY-MM-DD) is required by the API. */
|
|
595
|
+
function fetchExerciseStats(client, exerciseId, userId, date) {
|
|
596
|
+
return getJson(client, `/v5/exercises/${exerciseId}/stats?userId=${userId}&date=${date}`, "athlete exercise stats");
|
|
597
|
+
}
|
|
598
|
+
/** Scheduled + completed workouts in an inclusive YYYY-MM-DD window. */
|
|
599
|
+
function fetchAthleteWorkouts(client, startDate, endDate) {
|
|
600
|
+
return getArray(client, `/3.0/athlete/programworkout/range?startDate=${startDate}&endDate=${endDate}`, "athlete workouts");
|
|
601
|
+
}
|
|
602
|
+
function fetchLeaderboard(client, workoutId, opts = {}) {
|
|
603
|
+
const qs = new URLSearchParams();
|
|
604
|
+
if (opts.page !== void 0) qs.set("page", String(opts.page));
|
|
605
|
+
if (opts.pageSize !== void 0) qs.set("pageSize", String(opts.pageSize));
|
|
606
|
+
if (opts.gender !== void 0) qs.set("gender", String(opts.gender));
|
|
607
|
+
const query = qs.toString();
|
|
608
|
+
return getJson(client, `/3.0/athlete/leaderboard/${workoutId}${query ? `?${query}` : ""}`, "athlete leaderboard");
|
|
609
|
+
}
|
|
610
|
+
const SLOTS = 10;
|
|
611
|
+
function nonEmpty(value) {
|
|
612
|
+
return value !== void 0 && value !== null && String(value).trim() !== "";
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Per-set prescriptions from the param_N_data slots, e.g. ["5 @ 225", "3 @ 245"] or ["AMRAP"].
|
|
616
|
+
* Values are kept raw (a non-numeric prescription like "AMRAP" or "8-12" must survive); the
|
|
617
|
+
* positional units come from the exercise's param types, mirroring the coach presenter.
|
|
618
|
+
*/
|
|
619
|
+
function prescribedSets(ex) {
|
|
620
|
+
const out = [];
|
|
621
|
+
for (let i = 1; i <= SLOTS; i += 1) {
|
|
622
|
+
const p1 = ex[`param_1_data_${i}`];
|
|
623
|
+
const p2 = ex[`param_2_data_${i}`];
|
|
624
|
+
const has1 = nonEmpty(p1);
|
|
625
|
+
const has2 = nonEmpty(p2);
|
|
626
|
+
if (!has1 && !has2) continue;
|
|
627
|
+
if (has1 && has2) out.push(`${p1} @ ${p2}`);
|
|
628
|
+
else if (has1) out.push(String(p1));
|
|
629
|
+
else out.push(`@ ${p2}`);
|
|
630
|
+
}
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
function presentExercise(ex) {
|
|
634
|
+
const instruction = typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null;
|
|
635
|
+
return {
|
|
636
|
+
exerciseId: coerceInt(ex.exercise_id),
|
|
637
|
+
title: typeof ex.title === "string" ? ex.title : "",
|
|
638
|
+
instruction,
|
|
639
|
+
units: exerciseUnits(ex.param_1_type, ex.param_2_type),
|
|
640
|
+
prescribed: prescribedSets(ex)
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function presentBlock(set) {
|
|
644
|
+
const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
|
|
645
|
+
return {
|
|
646
|
+
order: coerceInt(set.order) ?? 0,
|
|
647
|
+
title: typeof set.title === "string" && set.title !== "" ? set.title : null,
|
|
648
|
+
instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
|
|
649
|
+
isTest: coerceInt(set.is_test) === 1,
|
|
650
|
+
exercises: exercises.filter(isRecord).map(presentExercise)
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
function str$1(v) {
|
|
654
|
+
return typeof v === "string" && v !== "" ? v : null;
|
|
655
|
+
}
|
|
656
|
+
/** Flatten one `/3.0/athlete/programworkout/range` item into a readable workout. */
|
|
657
|
+
function presentAthleteWorkout(raw) {
|
|
658
|
+
const rec = raw;
|
|
659
|
+
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
660
|
+
const workout = isRecord(ssw.workout) ? ssw.workout : {};
|
|
661
|
+
const sets = Array.isArray(workout.workoutSets) ? workout.workoutSets : [];
|
|
662
|
+
return {
|
|
663
|
+
id: coerceInt(rec.id),
|
|
664
|
+
date: str$1(rec.date) ?? "",
|
|
665
|
+
title: str$1(rec.workout_title) ?? "",
|
|
666
|
+
program: str$1(rec.program_title),
|
|
667
|
+
team: str$1(rec.team_title),
|
|
668
|
+
instruction: str$1(workout.instruction),
|
|
669
|
+
blocks: sets.filter(isRecord).map(presentBlock).sort((a, b) => a.order - b.order)
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function presentAthleteWorkouts(list) {
|
|
673
|
+
return list.map(presentAthleteWorkout);
|
|
674
|
+
}
|
|
675
|
+
const MAX_PARAM_SLOTS = 10;
|
|
676
|
+
/**
|
|
677
|
+
* Build the body for `PUT /1.0/athlete/savedworkoutsetexercise/{id}`. The body uses
|
|
678
|
+
* snake_case keys matching the live API response shape. Each set slot (1-10) gets a
|
|
679
|
+
* `param_N_made` flag (1 if data is present, 0 otherwise) and `param_1_data_N` /
|
|
680
|
+
* `param_2_data_N` string values.
|
|
681
|
+
*
|
|
682
|
+
* Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
|
|
683
|
+
* required from the live exercise record; everything else is derived from `results`.
|
|
684
|
+
*
|
|
685
|
+
* Exported for unit testing — callers should use `logAthleteSet` instead.
|
|
686
|
+
*/
|
|
687
|
+
function buildExerciseLogPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results) {
|
|
688
|
+
if (results.length > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${results.length}.`);
|
|
689
|
+
const body = {
|
|
690
|
+
id: savedWorkoutSetExerciseId,
|
|
691
|
+
saved_workout_set_id: savedWorkoutSetId,
|
|
692
|
+
workout_set_exercise_id: workoutSetExerciseId,
|
|
693
|
+
completed: results.some((s) => s.param1 !== void 0 || s.param2 !== void 0) ? 1 : 0
|
|
694
|
+
};
|
|
695
|
+
for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
|
|
696
|
+
const slot = results[i - 1];
|
|
697
|
+
const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
|
|
698
|
+
const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
|
|
699
|
+
body[`param_${i}_made`] = p1 !== "" || p2 !== "" ? 1 : 0;
|
|
700
|
+
body[`param_1_data_${i}`] = p1;
|
|
701
|
+
body[`param_2_data_${i}`] = p2;
|
|
702
|
+
}
|
|
703
|
+
return body;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Locate the target saved workout set across all program workouts on the given day.
|
|
707
|
+
* Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
|
|
708
|
+
* can look up `workout_set_exercise_id` by `savedWorkoutSetExerciseId`, and the raw set
|
|
709
|
+
* record so callers can build the set-completion PUT body via `buildSetCompletePayload`.
|
|
710
|
+
*/
|
|
711
|
+
function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
|
|
712
|
+
for (const pw of workouts) {
|
|
713
|
+
const rec = pw;
|
|
714
|
+
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
715
|
+
const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
|
|
716
|
+
if (!sw) continue;
|
|
717
|
+
const sets = Array.isArray(sw.workoutSets) ? sw.workoutSets : [];
|
|
718
|
+
for (const s of sets) {
|
|
719
|
+
if (!isRecord(s)) continue;
|
|
720
|
+
if (coerceInt(s.id) === savedWorkoutSetId) {
|
|
721
|
+
const savedWorkoutId = coerceInt(sw.id);
|
|
722
|
+
if (!savedWorkoutId) continue;
|
|
723
|
+
return {
|
|
724
|
+
savedWorkoutId,
|
|
725
|
+
exercises: Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises.filter(isRecord) : [],
|
|
726
|
+
rawSet: s
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
throw new Error(`Saved workout set ${savedWorkoutSetId} not found on this date.`);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Build the body for `PUT /1.0/athlete/savedworkoutset/{id}` that marks the set completed.
|
|
735
|
+
*
|
|
736
|
+
* The API requires the **app's camelCase in-memory model**, not the snake_case shape the
|
|
737
|
+
* GET endpoints return. Key field mappings from the raw set record:
|
|
738
|
+
* saved_workout_id → sessionId
|
|
739
|
+
* workout_set_id → workoutSetId
|
|
740
|
+
* is_super_set (0/1) → isSuperSet (boolean)
|
|
741
|
+
* plain_text (0/1) → isPlainText (boolean)
|
|
742
|
+
* unit ("lb"/"kg") → isMetric (boolean)
|
|
743
|
+
* workoutSetExercises[].id → exercises (array of IDs)
|
|
744
|
+
*
|
|
745
|
+
* `exerciseIds` must be the savedWorkoutSetExercise IDs (not workout_set_exercise_ids).
|
|
746
|
+
*
|
|
747
|
+
* Exported for unit testing — callers should use `logAthleteSet` instead.
|
|
748
|
+
*/
|
|
749
|
+
function buildSetCompletePayload(rawSet, exerciseIds, complete) {
|
|
750
|
+
const id = coerceInt(rawSet.id);
|
|
751
|
+
const sessionId = coerceInt(rawSet.saved_workout_id);
|
|
752
|
+
const workoutSetId = coerceInt(rawSet.workout_set_id);
|
|
753
|
+
if (!id || !sessionId || !workoutSetId) throw new Error("Raw set is missing required id / saved_workout_id / workout_set_id.");
|
|
754
|
+
const unit = typeof rawSet.unit === "string" ? rawSet.unit : "";
|
|
755
|
+
return {
|
|
756
|
+
id,
|
|
757
|
+
sessionId,
|
|
758
|
+
workoutSetId,
|
|
759
|
+
completed: complete ? "1" : "0",
|
|
760
|
+
rx: coerceInt(rawSet.rx) ?? 0,
|
|
761
|
+
version: coerceInt(rawSet.version) ?? 0,
|
|
762
|
+
isMetric: unit.toLowerCase() === "kg",
|
|
763
|
+
isSuperSet: rawSet.is_super_set === 1 || rawSet.is_super_set === true,
|
|
764
|
+
isPlainText: rawSet.plain_text === 1 || rawSet.plain_text === true,
|
|
765
|
+
title: typeof rawSet.title === "string" ? rawSet.title : "",
|
|
766
|
+
instruction: typeof rawSet.instruction === "string" ? rawSet.instruction : "",
|
|
767
|
+
notes: rawSet.notes ?? null,
|
|
768
|
+
exercises: [...exerciseIds]
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Record entered set results for one saved workout set.
|
|
773
|
+
*
|
|
774
|
+
* **Two-step write (both required for data to persist):**
|
|
775
|
+
* 1. For each exercise in `results`:
|
|
776
|
+
* `PUT /1.0/athlete/savedworkoutsetexercise/{savedWorkoutSetExerciseId}` with the
|
|
777
|
+
* per-slot `param_N_data_M` values. This is the only path that actually stores reps
|
|
778
|
+
* and weight — the `savedworkoutset` PUT accepts the same fields but silently discards
|
|
779
|
+
* them.
|
|
780
|
+
* 2. `PUT /1.0/athlete/savedworkoutset/{savedWorkoutSetId}` with `completed:"1"` to
|
|
781
|
+
* mark the block done and make the entry visible as a logged set in history.
|
|
782
|
+
*
|
|
783
|
+
* The date is used to locate the saved workout (via the range endpoint) so we can resolve
|
|
784
|
+
* `saved_workout_id` and `workout_set_exercise_id` for each exercise. Both are required
|
|
785
|
+
* by the respective PUT bodies.
|
|
786
|
+
*/
|
|
787
|
+
async function logAthleteSet(client, args) {
|
|
788
|
+
const { exercises, rawSet } = findSavedWorkoutSet(await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId);
|
|
789
|
+
let exercisesLogged = 0;
|
|
790
|
+
for (const result of args.results) {
|
|
791
|
+
const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
|
|
792
|
+
if (!ex) throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${args.savedWorkoutSetId}.`);
|
|
793
|
+
const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
|
|
794
|
+
if (!workoutSetExerciseId) throw new Error(`Could not resolve workout_set_exercise_id for exercise ${result.savedWorkoutSetExerciseId}.`);
|
|
795
|
+
const body = buildExerciseLogPayload(result.savedWorkoutSetExerciseId, args.savedWorkoutSetId, workoutSetExerciseId, result.sets);
|
|
796
|
+
const res = await client.request("PUT", `/1.0/athlete/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}`, { body });
|
|
797
|
+
if (!res.ok) throw new Error(`Failed to log exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).`);
|
|
798
|
+
exercisesLogged += 1;
|
|
799
|
+
}
|
|
800
|
+
const setBody = buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true);
|
|
801
|
+
const setRes = await client.request("PUT", `/1.0/athlete/savedworkoutset/${args.savedWorkoutSetId}`, { body: setBody });
|
|
802
|
+
if (!setRes.ok) throw new Error(`Failed to mark workout set ${args.savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
|
|
803
|
+
return {
|
|
804
|
+
savedWorkoutSetId: args.savedWorkoutSetId,
|
|
805
|
+
exercisesLogged
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
|
|
809
|
+
function presentExerciseHistory(detail) {
|
|
810
|
+
return {
|
|
811
|
+
liftPRs: (detail.liftPRs ?? []).map((p) => ({
|
|
812
|
+
description: p.description ?? null,
|
|
813
|
+
reps: p.reps ?? null,
|
|
814
|
+
weight: p.weight ?? null,
|
|
815
|
+
date: p.dateCompleted ?? null
|
|
816
|
+
})),
|
|
817
|
+
sessions: (detail.history ?? []).map((h) => ({
|
|
818
|
+
date: h.dateCompleted,
|
|
819
|
+
abr: h.abr ?? null,
|
|
820
|
+
estimated1RM: h.bestEstimated1RM ?? null,
|
|
821
|
+
sets: (h.sets ?? []).map((s) => ({
|
|
822
|
+
setNumber: s.setNumber,
|
|
823
|
+
value: s.formattedValue ?? null
|
|
824
|
+
}))
|
|
825
|
+
}))
|
|
826
|
+
};
|
|
827
|
+
}
|
|
347
828
|
//#endregion
|
|
348
829
|
//#region ../js/src/workout-encode.ts
|
|
349
830
|
const LEADERBOARD_TYPE = {
|
|
@@ -831,10 +1312,7 @@ var ExerciseLibrary = class {
|
|
|
831
1312
|
await this.ensureFresh();
|
|
832
1313
|
const s = this.#byId.get(id);
|
|
833
1314
|
if (!s) return null;
|
|
834
|
-
|
|
835
|
-
full.param_1_unit = unitLabel(full.param_1_type);
|
|
836
|
-
full.param_2_unit = unitLabel(full.param_2_type);
|
|
837
|
-
return full;
|
|
1315
|
+
return presentExercise$1(s.raw);
|
|
838
1316
|
}
|
|
839
1317
|
async defaults(id) {
|
|
840
1318
|
const s = this.#byId.get(id);
|
|
@@ -972,7 +1450,7 @@ const HELP = `trainheroic — command-line tool for the TrainHeroic coaching API
|
|
|
972
1450
|
Credentials come from TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD. Output is JSON.
|
|
973
1451
|
|
|
974
1452
|
Setup:
|
|
975
|
-
install-skill copy the Claude Code
|
|
1453
|
+
install-skill copy the Claude Code skills to ~/.claude/skills/
|
|
976
1454
|
|
|
977
1455
|
Reads:
|
|
978
1456
|
whoami | head-coach | athletes | programs | teams | notifications | analytics
|
|
@@ -1000,6 +1478,17 @@ Messaging:
|
|
|
1000
1478
|
message draft <streamId> <text> [--reply-to <id>]
|
|
1001
1479
|
message send <streamId> <text> [--reply-to <id>] --yes
|
|
1002
1480
|
message delete <streamId> <commentId> --yes
|
|
1481
|
+
|
|
1482
|
+
Athlete (the logged-in user's own training; a coach account works too):
|
|
1483
|
+
athlete whoami | profile [--metric] | prefs | working-maxes
|
|
1484
|
+
athlete workouts --start Y-M-D --end Y-M-D [--raw]
|
|
1485
|
+
athlete exercises [--q <text>] [--limit N]
|
|
1486
|
+
athlete history <exerciseId> [--raw]
|
|
1487
|
+
athlete prs <exerciseId>
|
|
1488
|
+
athlete stats <exerciseId> --date Y-M-D
|
|
1489
|
+
athlete leaderboard <workoutId> [--page N] [--page-size N] [--gender N]
|
|
1490
|
+
athlete export [--out dir] [--start Y-M-D] [--end Y-M-D] [--full]
|
|
1491
|
+
athlete log-set --date Y-M-D --set <id> <resultsJson>|--file f --yes (writes to your live log)
|
|
1003
1492
|
`;
|
|
1004
1493
|
function out(value) {
|
|
1005
1494
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
@@ -1236,17 +1725,182 @@ async function cmdMessage(client, rest) {
|
|
|
1236
1725
|
default: return fail("usage: trainheroic message <list|read|draft|send|delete>");
|
|
1237
1726
|
}
|
|
1238
1727
|
}
|
|
1728
|
+
function isoDate(value, label) {
|
|
1729
|
+
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) fail(`${label} must be YYYY-MM-DD, got "${value}".`);
|
|
1730
|
+
return value;
|
|
1731
|
+
}
|
|
1732
|
+
function logErr(msg) {
|
|
1733
|
+
process.stderr.write(`${msg}\n`);
|
|
1734
|
+
}
|
|
1735
|
+
async function cmdAthleteExport(client, a) {
|
|
1736
|
+
const { values } = parse(a, {
|
|
1737
|
+
out: { type: "string" },
|
|
1738
|
+
start: { type: "string" },
|
|
1739
|
+
end: { type: "string" },
|
|
1740
|
+
full: { type: "boolean" }
|
|
1741
|
+
});
|
|
1742
|
+
const dir = values.out ?? join(homedir(), ".trainheroic", "athlete-export");
|
|
1743
|
+
const today = /* @__PURE__ */ new Date();
|
|
1744
|
+
const past = /* @__PURE__ */ new Date();
|
|
1745
|
+
past.setFullYear(past.getFullYear() - 2);
|
|
1746
|
+
const start = values.start !== void 0 ? isoDate(values.start, "--start") : past.toISOString().slice(0, 10);
|
|
1747
|
+
const end = values.end !== void 0 ? isoDate(values.end, "--end") : today.toISOString().slice(0, 10);
|
|
1748
|
+
await mkdir(dir, { recursive: true });
|
|
1749
|
+
const userId = await resolveAthleteUserId(client);
|
|
1750
|
+
logErr(`exporting to ${dir} (user ${userId})`);
|
|
1751
|
+
const [summary, user, prefs, workouts, exercises, workingMaxes] = await Promise.all([
|
|
1752
|
+
fetchAthleteProfileSummary(client, userId),
|
|
1753
|
+
fetchAthleteUser(client, userId),
|
|
1754
|
+
fetchAthletePrefs(client),
|
|
1755
|
+
fetchAthleteWorkouts(client, start, end),
|
|
1756
|
+
fetchExerciseHistoryList(client),
|
|
1757
|
+
fetchWorkingMaxes(client)
|
|
1758
|
+
]);
|
|
1759
|
+
await writeFile(join(dir, "profile.json"), JSON.stringify({
|
|
1760
|
+
summary,
|
|
1761
|
+
user,
|
|
1762
|
+
prefs
|
|
1763
|
+
}, null, 2));
|
|
1764
|
+
await writeFile(join(dir, "workouts.json"), JSON.stringify(presentAthleteWorkouts(workouts), null, 2));
|
|
1765
|
+
await writeFile(join(dir, "exercises.json"), JSON.stringify(exercises, null, 2));
|
|
1766
|
+
await writeFile(join(dir, "working-maxes.json"), JSON.stringify(workingMaxes, null, 2));
|
|
1767
|
+
let histories = 0;
|
|
1768
|
+
if (values.full === true) {
|
|
1769
|
+
await mkdir(join(dir, "history"), { recursive: true });
|
|
1770
|
+
logErr(`fetching per-exercise history for ${exercises.length} exercises (--full)...`);
|
|
1771
|
+
await mapPool(exercises, 5, async (ex) => {
|
|
1772
|
+
const id = Number(ex.id);
|
|
1773
|
+
const detail = await fetchExerciseHistoryDetail(client, id, userId).catch(() => null);
|
|
1774
|
+
if (detail) {
|
|
1775
|
+
await writeFile(join(dir, "history", `${id}.json`), JSON.stringify(presentExerciseHistory(detail), null, 2));
|
|
1776
|
+
histories += 1;
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
out({
|
|
1781
|
+
exported: dir,
|
|
1782
|
+
range: {
|
|
1783
|
+
start,
|
|
1784
|
+
end
|
|
1785
|
+
},
|
|
1786
|
+
workouts: workouts.length,
|
|
1787
|
+
exercises: exercises.length,
|
|
1788
|
+
workingMaxes: workingMaxes.length,
|
|
1789
|
+
histories: values.full === true ? histories : "skipped (use --full)"
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
const LOG_SET_USAGE = "athlete log-set --date Y-M-D --set <id> <resultsJson> --yes";
|
|
1793
|
+
async function cmdAthleteLogSet(client, a) {
|
|
1794
|
+
const { values, positionals } = parse(a, {
|
|
1795
|
+
date: { type: "string" },
|
|
1796
|
+
set: { type: "string" },
|
|
1797
|
+
file: { type: "string" },
|
|
1798
|
+
yes: { type: "boolean" }
|
|
1799
|
+
});
|
|
1800
|
+
const date = isoDate(need(values.date, LOG_SET_USAGE), "--date");
|
|
1801
|
+
const savedWorkoutSetId = toInt(need(values.set, LOG_SET_USAGE), "--set");
|
|
1802
|
+
const args = validate(logSetArgsSchema, {
|
|
1803
|
+
date,
|
|
1804
|
+
savedWorkoutSetId,
|
|
1805
|
+
results: await jsonInput(positionals[0], values.file)
|
|
1806
|
+
}, "log-set args");
|
|
1807
|
+
if (values.yes !== true) fail(`logging to set ${savedWorkoutSetId} writes to your coach-visible log; add --yes.`);
|
|
1808
|
+
const mapped = args.results.map((r) => ({
|
|
1809
|
+
savedWorkoutSetExerciseId: toInt(String(r.savedWorkoutSetExerciseId), "savedWorkoutSetExerciseId"),
|
|
1810
|
+
sets: r.sets.map((s) => {
|
|
1811
|
+
const slot = {};
|
|
1812
|
+
if (s.param1 !== void 0) slot.param1 = s.param1;
|
|
1813
|
+
if (s.param2 !== void 0) slot.param2 = s.param2;
|
|
1814
|
+
return slot;
|
|
1815
|
+
})
|
|
1816
|
+
}));
|
|
1817
|
+
return out(await logAthleteSet(client, {
|
|
1818
|
+
date: args.date,
|
|
1819
|
+
savedWorkoutSetId,
|
|
1820
|
+
results: mapped
|
|
1821
|
+
}));
|
|
1822
|
+
}
|
|
1823
|
+
async function cmdAthlete(client, rest) {
|
|
1824
|
+
const [sub, ...a] = rest;
|
|
1825
|
+
switch (sub) {
|
|
1826
|
+
case "whoami": return out(await get(client, "/user/simple"));
|
|
1827
|
+
case "profile": {
|
|
1828
|
+
const { values } = parse(a, { metric: { type: "boolean" } });
|
|
1829
|
+
const userId = await resolveAthleteUserId(client);
|
|
1830
|
+
const [summary, user] = await Promise.all([fetchAthleteProfileSummary(client, userId, values.metric === true), fetchAthleteUser(client, userId)]);
|
|
1831
|
+
return out({
|
|
1832
|
+
summary,
|
|
1833
|
+
user
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
case "prefs": return out(await fetchAthletePrefs(client));
|
|
1837
|
+
case "workouts": {
|
|
1838
|
+
const { values } = parse(a, {
|
|
1839
|
+
start: { type: "string" },
|
|
1840
|
+
end: { type: "string" },
|
|
1841
|
+
raw: { type: "boolean" }
|
|
1842
|
+
});
|
|
1843
|
+
const workouts = await fetchAthleteWorkouts(client, isoDate(need(values.start, "athlete workouts --start Y-M-D --end Y-M-D"), "--start"), isoDate(need(values.end, "athlete workouts --start Y-M-D --end Y-M-D"), "--end"));
|
|
1844
|
+
return out(values.raw === true ? workouts : presentAthleteWorkouts(workouts));
|
|
1845
|
+
}
|
|
1846
|
+
case "exercises": {
|
|
1847
|
+
const { values } = parse(a, {
|
|
1848
|
+
q: { type: "string" },
|
|
1849
|
+
limit: { type: "string" }
|
|
1850
|
+
});
|
|
1851
|
+
const limit = values.limit !== void 0 ? toInt(values.limit, "--limit") : 20;
|
|
1852
|
+
const q = values.q;
|
|
1853
|
+
return out(q !== void 0 ? await searchExerciseHistory(client, q, limit) : await fetchExerciseHistoryList(client));
|
|
1854
|
+
}
|
|
1855
|
+
case "history": {
|
|
1856
|
+
const { values, positionals } = parse(a, { raw: { type: "boolean" } });
|
|
1857
|
+
const detail = await fetchExerciseHistoryDetail(client, toInt(need(positionals[0], "athlete history <exerciseId>"), "exerciseId"), await resolveAthleteUserId(client));
|
|
1858
|
+
return out(values.raw === true ? detail : presentExerciseHistory(detail));
|
|
1859
|
+
}
|
|
1860
|
+
case "prs": return out(await fetchPersonalRecords(client, toInt(need(a[0], "athlete prs <exerciseId>"), "exerciseId")));
|
|
1861
|
+
case "stats": {
|
|
1862
|
+
const { values, positionals } = parse(a, { date: { type: "string" } });
|
|
1863
|
+
const id = toInt(need(positionals[0], "athlete stats <exerciseId> --date Y-M-D"), "exerciseId");
|
|
1864
|
+
const date = isoDate(need(values.date, "athlete stats <exerciseId> --date Y-M-D"), "--date");
|
|
1865
|
+
return out(await fetchExerciseStats(client, id, await resolveAthleteUserId(client), date));
|
|
1866
|
+
}
|
|
1867
|
+
case "working-maxes": return out(await fetchWorkingMaxes(client));
|
|
1868
|
+
case "leaderboard": {
|
|
1869
|
+
const { values, positionals } = parse(a, {
|
|
1870
|
+
page: { type: "string" },
|
|
1871
|
+
"page-size": { type: "string" },
|
|
1872
|
+
gender: { type: "string" }
|
|
1873
|
+
});
|
|
1874
|
+
const workoutId = toInt(need(positionals[0], "athlete leaderboard <workoutId>"), "workoutId");
|
|
1875
|
+
const opts = {};
|
|
1876
|
+
if (values.page !== void 0) opts.page = toInt(values.page, "--page");
|
|
1877
|
+
if (values["page-size"] !== void 0) opts.pageSize = toInt(values["page-size"], "--page-size");
|
|
1878
|
+
if (values.gender !== void 0) opts.gender = toInt(values.gender, "--gender");
|
|
1879
|
+
return out(await fetchLeaderboard(client, workoutId, opts));
|
|
1880
|
+
}
|
|
1881
|
+
case "export": return cmdAthleteExport(client, a);
|
|
1882
|
+
case "log-set": return cmdAthleteLogSet(client, a);
|
|
1883
|
+
default: return fail("usage: trainheroic athlete <whoami|profile|prefs|workouts|exercises|history|prs|stats|working-maxes|leaderboard|export|log-set>");
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1239
1886
|
async function cmdInstallSkill() {
|
|
1240
1887
|
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
1241
1888
|
if (!home) fail("cannot determine home directory.");
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
await mkdir(
|
|
1245
|
-
await
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1889
|
+
const skillRoot = join(import.meta.dirname, "../skill");
|
|
1890
|
+
const skillsDir = join(home, ".claude/skills");
|
|
1891
|
+
await mkdir(skillsDir, { recursive: true });
|
|
1892
|
+
const entries = await readdir(skillRoot, { withFileTypes: true });
|
|
1893
|
+
const installed = [];
|
|
1894
|
+
for (const entry of entries) {
|
|
1895
|
+
if (!entry.isDirectory()) continue;
|
|
1896
|
+
const dest = join(skillsDir, entry.name);
|
|
1897
|
+
await cp(join(skillRoot, entry.name), dest, {
|
|
1898
|
+
recursive: true,
|
|
1899
|
+
force: true
|
|
1900
|
+
});
|
|
1901
|
+
installed.push(dest);
|
|
1902
|
+
}
|
|
1903
|
+
out({ installed });
|
|
1250
1904
|
}
|
|
1251
1905
|
async function dispatch(client, group, rest) {
|
|
1252
1906
|
switch (group) {
|
|
@@ -1264,6 +1918,7 @@ async function dispatch(client, group, rest) {
|
|
|
1264
1918
|
case "exercise": return cmdExercise(client, rest);
|
|
1265
1919
|
case "workout": return cmdWorkout(client, rest);
|
|
1266
1920
|
case "message": return cmdMessage(client, rest);
|
|
1921
|
+
case "athlete": return cmdAthlete(client, rest);
|
|
1267
1922
|
default: return fail(`unknown command "${group}". Run 'trainheroic help'.`);
|
|
1268
1923
|
}
|
|
1269
1924
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trainheroic-unofficial/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"zod": "^4.4.3",
|
|
24
|
-
"@trainheroic-unofficial/dto": "0.
|
|
25
|
-
"@trainheroic-unofficial/js": "0.
|
|
24
|
+
"@trainheroic-unofficial/dto": "0.4.0",
|
|
25
|
+
"@trainheroic-unofficial/js": "0.4.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.0.0",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: trainheroic-athlete
|
|
3
|
+
description: Read and analyze your own TrainHeroic training — scheduled and completed workouts, per-exercise history, PRs, working maxes, and lifetime totals — and optionally log completed sets. Use when the user wants to review their training history, track progress on a lift over time, look up PRs or working maxes, see what's programmed, export their training data, or log a workout result. Works for an athlete account or a coach account (a coach also has athlete-scoped training).
|
|
4
|
+
metadata:
|
|
5
|
+
author: alandotcom
|
|
6
|
+
version: "1.0.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# TrainHeroic Athlete API
|
|
10
|
+
|
|
11
|
+
Authenticated access to the logged-in user's **own** training through the `trainheroic`
|
|
12
|
+
command-line tool (`@trainheroic-unofficial/cli`), which wraps the
|
|
13
|
+
`@trainheroic-unofficial/js` SDK. This is the athlete surface: history, scheduled workouts,
|
|
14
|
+
PRs, working maxes. For coaching (rosters, teams, programming, messaging) use the
|
|
15
|
+
`trainheroic-unofficial` skill instead.
|
|
16
|
+
|
|
17
|
+
- Base URL: `https://api.trainheroic.com`
|
|
18
|
+
- Login endpoint: `https://apis.trainheroic.com/auth`
|
|
19
|
+
|
|
20
|
+
A coach account works here too: a coach login also carries athlete scope, so these commands
|
|
21
|
+
return the coach's own training data.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
### CLI
|
|
26
|
+
|
|
27
|
+
Verify the CLI is available, then install if missing (no credentials required):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
which trainheroic || npm install -g @trainheroic-unofficial/cli
|
|
31
|
+
trainheroic install-skill # refreshes skill files in ~/.claude/skills/
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Set `TH` for the commands below:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
TH="trainheroic"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Credentials
|
|
41
|
+
|
|
42
|
+
Credentials come from the environment (if unset, ask the user — do not guess):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export TRAINHEROIC_EMAIL="athlete@example.com"
|
|
46
|
+
export TRAINHEROIC_PASSWORD="..."
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The CLI logs in, caches the session at `~/.trainheroic/session.json`, and re-authenticates
|
|
50
|
+
on a 401/403. Start with `$TH athlete whoami` to confirm auth and get your `id`. Run
|
|
51
|
+
`$TH help` for the full command list.
|
|
52
|
+
|
|
53
|
+
## What you can do
|
|
54
|
+
|
|
55
|
+
| Goal | Command |
|
|
56
|
+
| ------------------------------------- | -------------------------------------------------------- |
|
|
57
|
+
| Lifetime totals + profile | `$TH athlete profile [--metric]` |
|
|
58
|
+
| Scheduled / completed workouts | `$TH athlete workouts --start Y-M-D --end Y-M-D [--raw]` |
|
|
59
|
+
| Find an exercise you've logged | `$TH athlete exercises [--q <text>] [--limit N]` |
|
|
60
|
+
| One lift's PRs + history over time | `$TH athlete history <exerciseId> [--raw]` |
|
|
61
|
+
| Personal records for a lift | `$TH athlete prs <exerciseId>` |
|
|
62
|
+
| Last performance + PR as of a date | `$TH athlete stats <exerciseId> --date Y-M-D` |
|
|
63
|
+
| Working maxes (drive % prescriptions) | `$TH athlete working-maxes` |
|
|
64
|
+
| Benchmark leaderboard | `$TH athlete leaderboard <workoutId>` |
|
|
65
|
+
| Download all historicals to JSON | `$TH athlete export [--out dir] [--full]` |
|
|
66
|
+
| Log completed set results (gated) | `$TH athlete log-set --date Y-M-D --set <id> ... --yes` |
|
|
67
|
+
|
|
68
|
+
## Reading training
|
|
69
|
+
|
|
70
|
+
Most analysis starts by finding the exercise id, then pulling its history:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
$TH athlete exercises --q "back squat" # -> id + title + positional units
|
|
74
|
+
$TH athlete history 1659830 # PRs + dated session time-series
|
|
75
|
+
$TH athlete stats 1659830 --date 2026-06-01
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`history` returns a presented view (PRs plus each session's date, summary `abr`, estimated
|
|
79
|
+
1RM, and sets). Add `--raw` for the untouched API object.
|
|
80
|
+
|
|
81
|
+
`workouts` flattens each day into blocks and exercises with per-set prescriptions and
|
|
82
|
+
positional units:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
$TH athlete workouts --start 2026-06-01 --end 2026-06-14
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`profile` returns lifetime totals (reps, volume, sessions, first/last logged, hours) plus
|
|
89
|
+
the profile. Pass `--metric` for kg/metric totals.
|
|
90
|
+
|
|
91
|
+
## Exporting your history
|
|
92
|
+
|
|
93
|
+
`$TH athlete export` writes your historicals to JSON files (default
|
|
94
|
+
`~/.trainheroic/athlete-export`): `profile.json`, `workouts.json` (a 2-year window by
|
|
95
|
+
default; override with `--start`/`--end`), `exercises.json` (the catalog), and
|
|
96
|
+
`working-maxes.json`. Add `--full` to also fetch each exercise's history into
|
|
97
|
+
`history/<id>.json` — that is one request per exercise (hundreds), so it is slower.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
$TH athlete export --out ./my-training # fast: workouts, catalog, maxes, profile
|
|
101
|
+
$TH athlete export --out ./my-training --full # also per-exercise history (slow)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Logging a set
|
|
105
|
+
|
|
106
|
+
`$TH athlete log-set` writes completed results back to a workout (reps/weight per set),
|
|
107
|
+
marks the set completed, and the result shows up in your exercise history. **It is
|
|
108
|
+
athlete-facing**: it mutates your training log, which your coach can see, so confirm before
|
|
109
|
+
running and re-read the workout to check the result landed.
|
|
110
|
+
|
|
111
|
+
Get the `savedWorkoutSetId` and each `savedWorkoutSetExerciseId` from the raw workout
|
|
112
|
+
(`$TH athlete workouts --start <day> --end <day> --raw`, under
|
|
113
|
+
`summarizedSavedWorkout.saved_workout.workoutSets`). Then:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
$TH athlete log-set --date 2026-06-01 --set 1593305783 --yes \
|
|
117
|
+
'[{"savedWorkoutSetExerciseId": 2712369448, "sets": [{"param1": 3, "param2": 225}]}]'
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`--yes` is required. Confirm with the user before running it, and re-read the workout to
|
|
121
|
+
check the result landed as intended. `param1`/`param2` are the entered values by entry slot
|
|
122
|
+
(check the exercise's positional units first).
|
|
123
|
+
|
|
124
|
+
## Gotchas
|
|
125
|
+
|
|
126
|
+
- `profile` must send `use_metric` and `stats` must send `date`, or the API returns 400.
|
|
127
|
+
The CLI fills `use_metric` (toggle with `--metric`); pass `--date` to `stats`.
|
|
128
|
+
- Units are **fixed per exercise** and surfaced positionally as `[param 1, param 2]` (e.g.
|
|
129
|
+
`["reps", "lb"]`). They are not labelled by role: some exercises reverse the slots.
|
|
130
|
+
- `log-set` writes to your coach-visible log. Gate it behind explicit user confirmation,
|
|
131
|
+
never run it autonomously, and verify the result. Its request shape was reverse-engineered
|
|
132
|
+
from the mobile app (a two-step write: log the exercise data, then mark the set completed).
|
|
133
|
+
- This API is undocumented and can change. `references/athlete-api.md` lists the endpoints
|
|
134
|
+
and shapes.
|
|
135
|
+
|
|
136
|
+
## Beyond the CLI
|
|
137
|
+
|
|
138
|
+
The same SDK powers the local `@trainheroic-unofficial/athlete-mcp` stdio server (the same
|
|
139
|
+
tools as MCP tools) and the hosted Cloudflare worker. The hosted worker adds a **training
|
|
140
|
+
warehouse** (D1): `athlete_workouts_sync` / `athlete_training_sync` download your
|
|
141
|
+
historicals, and `athlete_workouts_stored` / `athlete_training_stored` query them — so you
|
|
142
|
+
can research your training over time without re-hitting the API. See
|
|
143
|
+
`references/athlete-api.md` for the warehouse tools.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Athlete API reference
|
|
2
|
+
|
|
3
|
+
The athlete endpoints operate on the **logged-in user** (no athlete id in the path — the
|
|
4
|
+
session identifies you). Several need the numeric user id as a query arg; get it from
|
|
5
|
+
`GET /user/simple` (`.id`). All are on the default host `https://api.trainheroic.com` and
|
|
6
|
+
authenticate with the `session-token` header. Response schemas are loose (the API drifts);
|
|
7
|
+
only the fields below are relied on.
|
|
8
|
+
|
|
9
|
+
## Identity & profile
|
|
10
|
+
|
|
11
|
+
| Endpoint | Notes |
|
|
12
|
+
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `GET /user/simple` | `id`, `roles`, `org_id`, name. The id is the tenant key for everything else. |
|
|
14
|
+
| `GET /v5/athleteProfile/summary?user_id={id}&use_metric={0\|1}` | Lifetime totals: `reps_sum`, `volume_sum`, `sessions_count`, `first_logged_date`, `last_logged_date`, `duration_hours`. **`use_metric` is required** — omitting it returns `400 Invalid data`. |
|
|
15
|
+
| `GET /v5/users/{id}` | Detailed profile: dob, gender, height/weight, `use_metric`, trial status. |
|
|
16
|
+
| `GET /1.0/athlete/prefs` | Notification + display preference flags. |
|
|
17
|
+
|
|
18
|
+
## Workouts
|
|
19
|
+
|
|
20
|
+
`GET /3.0/athlete/programworkout/range?startDate={Y-M-D}&endDate={Y-M-D}` — scheduled and
|
|
21
|
+
completed workouts in an inclusive window. Each item:
|
|
22
|
+
|
|
23
|
+
- Top level: `id` (the programWorkout id), `date`, `workout_title`, `program_id`,
|
|
24
|
+
`program_title`, `team_id`, `team_title`.
|
|
25
|
+
- `summarizedSavedWorkout.workout`: `title`, `instruction` (coach note), `workoutSets[]`.
|
|
26
|
+
- Each set: `title`, `order`, `instruction`, `is_test`, `workoutSetExercises[]`.
|
|
27
|
+
- Each exercise: `exercise_id`, `title`, `instruction`, `param_1_type`, `param_2_type`,
|
|
28
|
+
and the prescription in `param_1_data_1..10` / `param_2_data_1..10` (one slot per set;
|
|
29
|
+
empty string for unused). Non-numeric prescriptions (`AMRAP`, `8-12`) appear as-is.
|
|
30
|
+
- `summarizedSavedWorkout.saved_workout`: the athlete's logged copy, with `workoutSets[]`
|
|
31
|
+
whose `id` is the **savedWorkoutSetId** and whose `workoutSetExercises[].id` is the
|
|
32
|
+
**savedWorkoutSetExerciseId** — the ids the logging write targets.
|
|
33
|
+
|
|
34
|
+
The SDK's `presentAthleteWorkout` flattens this to `{ id, date, title, program, team,
|
|
35
|
+
instruction, blocks: [{ order, title, instruction, isTest, exercises: [{ exerciseId, title,
|
|
36
|
+
instruction, units, prescribed }] }] }`.
|
|
37
|
+
|
|
38
|
+
## Exercises, history, PRs
|
|
39
|
+
|
|
40
|
+
| Endpoint | Notes |
|
|
41
|
+
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
42
|
+
| `GET /v5/users/exercises/history` | The exercises you've logged: `id`, `title`, `param1Type`, `param2Type`, `prescription`, `isCircuit`. Use it to find an exercise id. |
|
|
43
|
+
| `GET /v5/exercises/{id}/history?userId={id}` | `liftPRs[]` (rep-max PRs), `history[]` (per-session: `dateCompleted`, `abr`, `bestEstimated1RM`, `savedWorkoutSetExerciseId`, `programWorkoutId`, `sets[]`, `repMaxes[]`). |
|
|
44
|
+
| `GET /v5/exercises/{id}/personalRecords` | PR rows with strength-standard `filters`. |
|
|
45
|
+
| `GET /v5/exercises/{id}/stats?userId={id}&date={Y-M-D}` | `isLift`, `lastPerformance`, `personalRecord`. **`date` is required** — omitting it returns `400 Invalid date parameter`. |
|
|
46
|
+
| `GET /v5/exercises/{id}` | Exercise detail: description, `param1Type`/`param2Type`, `units`, video. |
|
|
47
|
+
| `GET /2.0/athlete/workingMax` | Working max per exercise: `exercise_id`, `title`, `param_type`, `value`, `type_suffix`. |
|
|
48
|
+
| `GET /3.0/athlete/leaderboard/{workoutId}` | Leaderboard for a benchmark/test workout (`tests`, `results`, `testStats`). |
|
|
49
|
+
|
|
50
|
+
## Logging a set (write)
|
|
51
|
+
|
|
52
|
+
Logging is a **two-step** write, reverse-engineered from the mobile app (verified against
|
|
53
|
+
captured traffic). The SDK's `logAthleteSet` performs both; it fetches the day's range to
|
|
54
|
+
resolve the ids from `summarizedSavedWorkout.saved_workout`.
|
|
55
|
+
|
|
56
|
+
1. **Persist the entered data** — `PUT /1.0/athlete/savedworkoutsetexercise/{savedWorkoutSetExerciseId}`
|
|
57
|
+
with `{ id, saved_workout_set_id, workout_set_exercise_id, completed:1, param_N_made,
|
|
58
|
+
param_1_data_N, param_2_data_N (10 slots) }`. This is the only path that actually stores
|
|
59
|
+
reps/weight (and it alone surfaces the result in exercise history). The
|
|
60
|
+
`savedworkoutset`/`savedworkout` PUTs accept the same fields but **silently discard** the
|
|
61
|
+
`param_N_data` values.
|
|
62
|
+
2. **Mark the set completed** — `PUT /1.0/athlete/savedworkoutset/{savedWorkoutSetId}` with
|
|
63
|
+
the camelCase in-memory model (`sessionId` ← `saved_workout.id`, `workoutSetId` ←
|
|
64
|
+
`workout_set_id`, `isSuperSet`, `exercises: [savedWorkoutSetExerciseId, …]`, `completed`).
|
|
65
|
+
A minimal `{id, sessionId, completed}` body returns 500 — the full mapped body is required.
|
|
66
|
+
|
|
67
|
+
There is no GET for a single saved workout set; read it from the workout range's
|
|
68
|
+
`saved_workout.workoutSets[]` (`id` = savedWorkoutSetId, `workoutSetExercises[].id` =
|
|
69
|
+
savedWorkoutSetExerciseId, `workoutSetExercises[].workout_set_exercise_id` = the template id).
|
|
70
|
+
|
|
71
|
+
## MCP tools
|
|
72
|
+
|
|
73
|
+
The local `@trainheroic-unofficial/athlete-mcp` server and the hosted worker (for any
|
|
74
|
+
account) expose:
|
|
75
|
+
|
|
76
|
+
- Live reads: `athlete_whoami`, `athlete_profile`, `athlete_prefs`, `athlete_workouts`,
|
|
77
|
+
`athlete_exercises`, `athlete_exercise_history`, `athlete_personal_records`,
|
|
78
|
+
`athlete_exercise_stats`, `athlete_working_maxes`, `athlete_leaderboard`.
|
|
79
|
+
- Gated write: `athlete_log_set` (elicitation or `confirm:true`).
|
|
80
|
+
|
|
81
|
+
## Warehouse tools (hosted worker only, D1-backed)
|
|
82
|
+
|
|
83
|
+
Download historicals into D1 so they can be queried over time without re-hitting the API.
|
|
84
|
+
One sync verb populates each zone; one query tool reads it.
|
|
85
|
+
|
|
86
|
+
- `athlete_workouts_sync { startDate, endDate }` → `athlete_workouts_stored { workoutId? |
|
|
87
|
+
startDate?/endDate? }`.
|
|
88
|
+
- `athlete_training_sync { exerciseId? | batchSize?, full? }` → `athlete_training_stored
|
|
89
|
+
{ q? | exerciseId? (+prs?) | workingMaxes? }`. Omitting `exerciseId` syncs the catalog +
|
|
90
|
+
working maxes and drains a **batch** of un-synced exercises (repeat until `remaining` is 0
|
|
91
|
+
— bounded per call to respect Worker subrequest limits). `full:true` re-pulls every
|
|
92
|
+
exercise.
|
|
93
|
+
|
|
94
|
+
## Still unexplored
|
|
95
|
+
|
|
96
|
+
- `PUT /1.0/athlete/savedworkout/{id}` (whole-workout sync; not needed for per-set logging,
|
|
97
|
+
which uses the two-step path above).
|
|
98
|
+
- `GET /v5/users/circuits/{recent,history}` (circuit history; shapes mirror the exercise
|
|
99
|
+
history list).
|
|
100
|
+
- `GET /1.0/athlete/programming/programs` (subscribed programs; empty for the test account).
|