trainerroad-cli 0.1.1 → 0.2.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 { queryWorkoutLibrary } from "./workout-library.mjs";
2
+
3
+ async function requirePrivateMember(flags, deps) {
4
+ const { withClient } = deps;
5
+ const client = await withClient(flags);
6
+ try {
7
+ const memberInfo = await client.getMemberInfo();
8
+ return { client, memberInfo };
9
+ } catch {
10
+ throw new Error(
11
+ "workout-recommend requires private authenticated mode. Login first with trainerroad-cli login.",
12
+ );
13
+ }
14
+ }
15
+
16
+ function numericOrNull(value, deps) {
17
+ if (value == null) return null;
18
+ return deps.requireNumber(value, null);
19
+ }
20
+
21
+ function deriveTarget(explicit, min, max) {
22
+ if (explicit != null) return explicit;
23
+ if (min != null && max != null) return (min + max) / 2;
24
+ return null;
25
+ }
26
+
27
+ function compareValues(left, right) {
28
+ if (left === right) return 0;
29
+ if (left == null) return 1;
30
+ if (right == null) return -1;
31
+ if (typeof left === "string" || typeof right === "string") {
32
+ return String(left).localeCompare(String(right));
33
+ }
34
+ return Number(left) - Number(right);
35
+ }
36
+
37
+ function scoreWorkout(item, targets) {
38
+ let score = 0;
39
+ const reasons = [];
40
+
41
+ if (targets.level != null && item.progressionLevel != null) {
42
+ const delta = Math.abs(item.progressionLevel - targets.level);
43
+ score += delta * 10;
44
+ reasons.push({ metric: "level", target: targets.level, actual: item.progressionLevel, delta });
45
+ }
46
+
47
+ if (targets.duration != null && item.durationMinutes != null) {
48
+ const delta = Math.abs(item.durationMinutes - targets.duration);
49
+ score += delta / 5;
50
+ reasons.push({ metric: "duration", target: targets.duration, actual: item.durationMinutes, delta });
51
+ }
52
+
53
+ if (targets.tss != null && item.tss != null) {
54
+ const delta = Math.abs(item.tss - targets.tss);
55
+ score += delta / 10;
56
+ reasons.push({ metric: "tss", target: targets.tss, actual: item.tss, delta });
57
+ }
58
+
59
+ if (targets.searchText) {
60
+ const text = String(item.workoutName ?? "").toLowerCase();
61
+ const query = String(targets.searchText).toLowerCase();
62
+ if (text === query) score -= 2;
63
+ else if (text.includes(query)) score -= 1;
64
+ }
65
+
66
+ if (item.hasInstructions) score -= 0.15;
67
+ if (item.isOutside === false) score -= 0.05;
68
+
69
+ return { score, reasons };
70
+ }
71
+
72
+ export async function commandWorkoutRecommend(flags, deps) {
73
+ const { isJsonMode, requirePositiveInteger, requireNumber, toBoolean, writeOutput } = deps;
74
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
75
+
76
+ const count = requirePositiveInteger(flags.count, 5);
77
+ const candidateLimit = requirePositiveInteger(flags["candidate-limit"], 100);
78
+ const minDuration = numericOrNull(flags["min-duration"], deps);
79
+ const maxDuration = numericOrNull(flags["max-duration"], deps);
80
+ const minTss = numericOrNull(flags["min-tss"], deps);
81
+ const maxTss = numericOrNull(flags["max-tss"], deps);
82
+ const minLevel = numericOrNull(flags["min-level"], deps);
83
+ const maxLevel = numericOrNull(flags["max-level"], deps);
84
+ const targetDuration = deriveTarget(numericOrNull(flags["target-duration"], deps), minDuration, maxDuration);
85
+ const targetTss = deriveTarget(numericOrNull(flags["target-tss"], deps), minTss, maxTss);
86
+ const targetLevel = deriveTarget(numericOrNull(flags["target-level"], deps), minLevel, maxLevel);
87
+ const searchText = String(flags.search ?? "").trim();
88
+
89
+ const library = await queryWorkoutLibrary(client, memberInfo, {
90
+ searchText,
91
+ zone: flags.zone,
92
+ zoneId: flags["zone-id"],
93
+ profile: flags.profile,
94
+ profileId: flags["profile-id"],
95
+ outside: flags.outside == null ? null : toBoolean(flags.outside, false),
96
+ hasInstructions:
97
+ flags["has-instructions"] == null ? null : toBoolean(flags["has-instructions"], false),
98
+ minDuration,
99
+ maxDuration,
100
+ minTss,
101
+ maxTss,
102
+ minLevel,
103
+ maxLevel,
104
+ sort: String(flags.sort ?? "level"),
105
+ limit: candidateLimit,
106
+ pageSize: requirePositiveInteger(flags["page-size"], 50),
107
+ });
108
+
109
+ const targets = {
110
+ duration: targetDuration,
111
+ tss: targetTss,
112
+ level: targetLevel,
113
+ searchText,
114
+ };
115
+
116
+ const recommendations = library.records
117
+ .map((item) => {
118
+ const ranking = scoreWorkout(item, targets);
119
+ return { ...item, recommendationScore: ranking.score, rationale: ranking.reasons };
120
+ })
121
+ .sort(
122
+ (a, b) =>
123
+ compareValues(a.recommendationScore, b.recommendationScore) ||
124
+ compareValues(a.progressionLevel, b.progressionLevel) ||
125
+ compareValues(a.durationMinutes, b.durationMinutes) ||
126
+ compareValues(a.workoutName, b.workoutName),
127
+ )
128
+ .slice(0, count);
129
+
130
+ const payload = {
131
+ generatedAt: new Date().toISOString(),
132
+ command: "workout-recommend",
133
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
134
+ query: {
135
+ searchText,
136
+ zone: flags.zone ?? null,
137
+ zoneId: flags["zone-id"] ?? null,
138
+ profile: flags.profile ?? null,
139
+ profileId: flags["profile-id"] ?? null,
140
+ outside: flags.outside ?? null,
141
+ hasInstructions: flags["has-instructions"] ?? null,
142
+ minDuration,
143
+ maxDuration,
144
+ minTss,
145
+ maxTss,
146
+ minLevel,
147
+ maxLevel,
148
+ targetDuration,
149
+ targetTss,
150
+ targetLevel,
151
+ count,
152
+ candidateLimit,
153
+ },
154
+ fetch: library.fetch,
155
+ recommendationCount: recommendations.length,
156
+ records: recommendations,
157
+ };
158
+
159
+ if (!isJsonMode(flags)) {
160
+ await writeOutput(payload, flags, (value) => {
161
+ const lines = [
162
+ `Workout recommendations (${value.recommendationCount}) candidates=${value.fetch.fetchedCount}/${value.fetch.serverTotalCount ?? "?"}`,
163
+ ];
164
+ for (const item of value.records) {
165
+ lines.push(
166
+ `- ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | score=${item.recommendationScore.toFixed(2)} | level=${item.progressionLevel ?? "?"} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"}`,
167
+ );
168
+ }
169
+ return lines.join("\n");
170
+ });
171
+ return;
172
+ }
173
+
174
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
175
+ }
@@ -0,0 +1,326 @@
1
+ function stripHtml(value) {
2
+ return String(value ?? "")
3
+ .replace(/<[^>]+>/g, " ")
4
+ .replace(/&nbsp;/gi, " ")
5
+ .replace(/&amp;/gi, "&")
6
+ .replace(/&quot;/gi, '"')
7
+ .replace(/&#39;/gi, "'")
8
+ .replace(/\s+/g, " ")
9
+ .trim();
10
+ }
11
+
12
+ function summarizeWorkout(workout) {
13
+ if (!workout || typeof workout !== "object") return null;
14
+ return {
15
+ workoutId: workout.id ?? null,
16
+ workoutName: workout.workoutName ?? workout.name ?? null,
17
+ zoneId: workout.progressionId ?? workout.progression?.id ?? null,
18
+ zoneName: workout.progression?.text ?? null,
19
+ profileId: workout.profileId ?? null,
20
+ profileName: workout.profileName ?? null,
21
+ durationMinutes: workout.duration ?? null,
22
+ tss: workout.tss ?? null,
23
+ intensityFactor: workout.intensityFactor ?? null,
24
+ averageFtpPercent: workout.averageFtpPercent ?? null,
25
+ progressionLevel: workout.progressionLevel ?? null,
26
+ workoutDifficultyRating: workout.workoutDifficultyRating ?? null,
27
+ workoutTypeId: workout.workoutTypeId ?? null,
28
+ workoutLabelId: workout.workoutLabelId ?? null,
29
+ isOutside: workout.isOutside ?? null,
30
+ hasInstructions: workout.hasInstructions ?? null,
31
+ firstPublishDate: workout.firstPublishDate ?? null,
32
+ indoorAlternativeId: workout.indoorAlternativeId ?? null,
33
+ energyKj: workout.kj ?? null,
34
+ goal: stripHtml(workout.goalDescription),
35
+ description: stripHtml(workout.workoutDescription),
36
+ };
37
+ }
38
+
39
+ function summarizePlannedActivity(activity) {
40
+ if (!activity || typeof activity !== "object") return null;
41
+ const workout = activity.workout ?? null;
42
+ const date = activity.date
43
+ ? `${String(activity.date.year).padStart(4, "0")}-${String(activity.date.month).padStart(2, "0")}-${String(activity.date.day).padStart(2, "0")}`
44
+ : null;
45
+ return {
46
+ plannedActivityId: activity.id ?? null,
47
+ date,
48
+ timeOfDay: activity.timeOfDay ?? null,
49
+ workoutId: workout?.id ?? null,
50
+ workoutName: workout?.name ?? activity.name ?? null,
51
+ isOutside: workout?.isOutside ?? null,
52
+ durationMinutes:
53
+ workout?.duration ??
54
+ (Number.isFinite(Number(activity.durationInSeconds))
55
+ ? Math.round(Number(activity.durationInSeconds) / 60)
56
+ : null),
57
+ tss: activity.tss ?? workout?.tss ?? null,
58
+ recommendationReason: activity.recommendationReason ?? null,
59
+ modified: activity.modified ?? null,
60
+ };
61
+ }
62
+
63
+ function summarizeLevels(levelsPayload) {
64
+ return {
65
+ workoutLevels: Array.isArray(levelsPayload?.workoutLevels) ? levelsPayload.workoutLevels : [],
66
+ athleteLevels:
67
+ levelsPayload && typeof levelsPayload.athleteLevels === "object" ? levelsPayload.athleteLevels : {},
68
+ };
69
+ }
70
+
71
+ function summarizeChart(chartData, pointLimit = 200) {
72
+ const rawPoints = Array.isArray(chartData?.courseData) ? chartData.courseData : [];
73
+ const pointsUseMilliseconds =
74
+ rawPoints.length > 1 && Number(rawPoints[1]?.seconds) >= 1000 && Number(rawPoints[1]?.seconds) % 1000 === 0;
75
+ const points = rawPoints.map((point) => ({
76
+ ...point,
77
+ seconds:
78
+ pointsUseMilliseconds && Number.isFinite(Number(point?.seconds))
79
+ ? Number(point.seconds) / 1000
80
+ : point?.seconds ?? null,
81
+ }));
82
+ let minPercent = null;
83
+ let maxPercent = null;
84
+ let durationSeconds = 0;
85
+ for (const point of points) {
86
+ const ftpPercent = Number(point?.ftpPercent);
87
+ if (Number.isFinite(ftpPercent)) {
88
+ minPercent = minPercent == null ? ftpPercent : Math.min(minPercent, ftpPercent);
89
+ maxPercent = maxPercent == null ? ftpPercent : Math.max(maxPercent, ftpPercent);
90
+ }
91
+ const seconds = Number(point?.seconds);
92
+ if (Number.isFinite(seconds)) durationSeconds = Math.max(durationSeconds, seconds);
93
+ }
94
+ return {
95
+ pointCount: points.length,
96
+ durationSeconds,
97
+ minFtpPercent: minPercent,
98
+ maxFtpPercent: maxPercent,
99
+ points: points.slice(0, pointLimit),
100
+ };
101
+ }
102
+
103
+ function requireFlag(flags, name) {
104
+ const value = flags[name];
105
+ if (value === undefined || value === null || value === "") {
106
+ throw new Error(`Missing required flag --${name}.`);
107
+ }
108
+ return value;
109
+ }
110
+
111
+ async function requirePrivateMember(flags, deps) {
112
+ const { withClient } = deps;
113
+ const client = await withClient(flags);
114
+ try {
115
+ const memberInfo = await client.getMemberInfo();
116
+ return { client, memberInfo };
117
+ } catch {
118
+ throw new Error(
119
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
120
+ );
121
+ }
122
+ }
123
+
124
+ async function sleep(ms) {
125
+ await new Promise((resolve) => setTimeout(resolve, ms));
126
+ }
127
+
128
+ async function findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId) {
129
+ const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
130
+ const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
131
+ .filter((item) => {
132
+ const date = item?.date;
133
+ const asIso = date
134
+ ? `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`
135
+ : null;
136
+ return asIso === dateIso;
137
+ })
138
+ .map((item) => item.id)
139
+ .filter(Boolean);
140
+
141
+ if (candidateIds.length === 0) return null;
142
+ const details = await client.getPlannedActivitiesByIds(
143
+ memberInfo.memberId,
144
+ memberInfo.username,
145
+ candidateIds,
146
+ );
147
+ const matches = details.filter((item) => Number(item?.workout?.id) === Number(workoutId));
148
+ if (matches.length === 0) return null;
149
+ return matches.sort((a, b) => String(b.modified ?? "").localeCompare(String(a.modified ?? "")))[0];
150
+ }
151
+
152
+ async function listPlannedWorkoutsOnDate(client, memberInfo, dateIso) {
153
+ const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
154
+ const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
155
+ .filter((item) => {
156
+ const date = item?.date;
157
+ const asIso = date
158
+ ? `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`
159
+ : null;
160
+ return asIso === dateIso;
161
+ })
162
+ .map((item) => item.id)
163
+ .filter(Boolean);
164
+
165
+ if (candidateIds.length === 0) return [];
166
+ return client.getPlannedActivitiesByIds(memberInfo.memberId, memberInfo.username, candidateIds);
167
+ }
168
+
169
+ export async function commandWorkoutDetails(flags, deps) {
170
+ const { isJsonMode, requirePositiveInteger, toBoolean, writeOutput } = deps;
171
+ const workoutId = Number(requireFlag(flags, "id"));
172
+ if (!Number.isFinite(workoutId)) {
173
+ throw new Error(`Invalid --id "${flags.id}". Expected a numeric workout ID.`);
174
+ }
175
+
176
+ const includeChart = toBoolean(flags["include-chart"], false);
177
+ const chartPointLimit = requirePositiveInteger(flags["chart-point-limit"], 200);
178
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
179
+
180
+ const [byIdRows, summaryPayload, levelsPayload, chartData] = await Promise.all([
181
+ client.getWorkoutsByIds([workoutId], memberInfo.username),
182
+ client.getWorkoutSummary(workoutId, memberInfo.username),
183
+ client.getWorkoutLevels(workoutId, memberInfo.username),
184
+ includeChart ? client.getWorkoutChartData(workoutId, memberInfo.username) : Promise.resolve(null),
185
+ ]);
186
+
187
+ const byIdWorkout = Array.isArray(byIdRows) && byIdRows.length > 0 ? byIdRows[0] : null;
188
+ const summaryWorkout = summaryPayload?.summary ?? null;
189
+ const workout = summarizeWorkout(summaryWorkout ?? byIdWorkout);
190
+ const payload = {
191
+ generatedAt: new Date().toISOString(),
192
+ command: "workout-details",
193
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
194
+ query: { workoutId, includeChart, chartPointLimit },
195
+ workout,
196
+ levels: summarizeLevels(levelsPayload),
197
+ chart: includeChart ? summarizeChart(chartData, chartPointLimit) : undefined,
198
+ };
199
+
200
+ if (!isJsonMode(flags)) {
201
+ await writeOutput(payload, flags, (value) => {
202
+ const lines = [
203
+ `${value.workout?.workoutName ?? "Workout"} | workoutId=${value.query.workoutId} | zone=${value.workout?.zoneName ?? value.workout?.zoneId ?? "?"} | profile=${value.workout?.profileName ?? value.workout?.profileId ?? "?"}`,
204
+ `duration=${value.workout?.durationMinutes ?? "?"}m | tss=${value.workout?.tss ?? "?"} | if=${value.workout?.intensityFactor ?? "?"} | level=${value.workout?.progressionLevel ?? "?"} | outside=${value.workout?.isOutside}`,
205
+ ];
206
+ if (value.chart) {
207
+ lines.push(
208
+ `chart: points=${value.chart.pointCount} duration=${value.chart.durationSeconds}s ftp%=${value.chart.minFtpPercent ?? "?"}-${value.chart.maxFtpPercent ?? "?"}`,
209
+ );
210
+ }
211
+ return lines.join("\n");
212
+ });
213
+ return;
214
+ }
215
+
216
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
217
+ }
218
+
219
+ export async function commandAddWorkout(flags, deps) {
220
+ const { isJsonMode, toBoolean, writeOutput } = deps;
221
+ const workoutId = Number(requireFlag(flags, "workout-id"));
222
+ const dateIso = String(requireFlag(flags, "date"));
223
+ if (!Number.isFinite(workoutId)) {
224
+ throw new Error(`Invalid --workout-id "${flags["workout-id"]}". Expected a numeric workout ID.`);
225
+ }
226
+
227
+ const outside = toBoolean(flags.outside, false);
228
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
229
+ const workoutRows = await client.getWorkoutsByIds([workoutId], memberInfo.username);
230
+ const workout = summarizeWorkout(Array.isArray(workoutRows) ? workoutRows[0] : null);
231
+ if (!workout) {
232
+ throw new Error(`Workout ${workoutId} was not found in the library.`);
233
+ }
234
+
235
+ const attempts = await client.tryAddWorkoutToCalendar(workoutId, dateIso, {
236
+ outside,
237
+ usernameForReferer: memberInfo.username,
238
+ });
239
+
240
+ let created = null;
241
+ for (let attempt = 0; attempt < 4; attempt += 1) {
242
+ created = await findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId);
243
+ if (created) break;
244
+ await sleep(750);
245
+ }
246
+
247
+ if (!created) {
248
+ throw new Error(
249
+ `TrainerRoad did not expose a confirmed added workout for ${dateIso}. Attempts: ${JSON.stringify(attempts)}`,
250
+ );
251
+ }
252
+
253
+ const payload = {
254
+ generatedAt: new Date().toISOString(),
255
+ command: "add-workout",
256
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
257
+ query: { workoutId, date: dateIso, outside },
258
+ workout,
259
+ created: summarizePlannedActivity(created),
260
+ attempts,
261
+ warnings: attempts.some((item) => !item.ok)
262
+ ? [
263
+ "TrainerRoad returned one or more non-2xx responses during add-workout, but the workout was observed on the calendar after reconciliation.",
264
+ ]
265
+ : [],
266
+ };
267
+
268
+ if (!isJsonMode(flags)) {
269
+ await writeOutput(payload, flags, (value) => {
270
+ const warning = value.warnings.length > 0 ? " | warning=server-status-mismatch" : "";
271
+ return `Added ${value.workout?.workoutName ?? "workout"} to ${value.query.date} | plannedActivityId=${value.created?.plannedActivityId}${warning}`;
272
+ });
273
+ return;
274
+ }
275
+
276
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
277
+ }
278
+
279
+ export async function commandCopyWorkout(flags, deps) {
280
+ const { isJsonMode, writeOutput } = deps;
281
+ const sourcePlannedActivityId = String(requireFlag(flags, "id"));
282
+ const targetDate = String(requireFlag(flags, "date"));
283
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
284
+ const source = await client.getPlannedActivity(sourcePlannedActivityId, memberInfo.username);
285
+ const beforeTarget = await listPlannedWorkoutsOnDate(client, memberInfo, targetDate);
286
+ const beforeIds = new Set(beforeTarget.map((item) => item.id));
287
+
288
+ const mutation = await client.copyPlannedActivity(sourcePlannedActivityId, targetDate, memberInfo.username);
289
+
290
+ let created = null;
291
+ for (let attempt = 0; attempt < 5; attempt += 1) {
292
+ const afterTarget = await listPlannedWorkoutsOnDate(client, memberInfo, targetDate);
293
+ created = afterTarget.find(
294
+ (item) =>
295
+ !beforeIds.has(item.id) &&
296
+ Number(item?.workout?.id) === Number(source?.workout?.id),
297
+ );
298
+ if (created) break;
299
+ await sleep(500);
300
+ }
301
+
302
+ if (!created) {
303
+ throw new Error(
304
+ `Copy completed but no new planned workout was found on ${targetDate} for source ${sourcePlannedActivityId}.`,
305
+ );
306
+ }
307
+
308
+ const payload = {
309
+ generatedAt: new Date().toISOString(),
310
+ command: "copy-workout",
311
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
312
+ query: { sourcePlannedActivityId, date: targetDate },
313
+ source: summarizePlannedActivity(source),
314
+ created: summarizePlannedActivity(created),
315
+ mutation,
316
+ };
317
+
318
+ if (!isJsonMode(flags)) {
319
+ await writeOutput(payload, flags, (value) => {
320
+ return `Copied ${value.source?.workoutName ?? "workout"} to ${value.query.date} | plannedActivityId=${value.created?.plannedActivityId}`;
321
+ });
322
+ return;
323
+ }
324
+
325
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
326
+ }
@@ -37,6 +37,12 @@ export const COMMANDS = {
37
37
  "node src/cli.mjs timeline --target quinnsprouse --public --json",
38
38
  ],
39
39
  },
40
+ "train-now": {
41
+ summary: "Fetch TrainerRoad AI suggested workouts (TrainNow) for a target duration (private mode).",
42
+ usage: [
43
+ "node src/cli.mjs train-now [--duration <minutes>] [--num-suggestions <n>] [--category climbing|endurance|attacking] [--json|--jsonl]",
44
+ ],
45
+ },
40
46
  events: {
41
47
  summary: "Show calendar events/races from timeline (private mode).",
42
48
  usage: [
@@ -105,6 +111,60 @@ export const COMMANDS = {
105
111
  "node src/cli.mjs power-records [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--row-type 100|101] [--indoor-only true|false] [--limit <n>] [--full] [--json|--jsonl]",
106
112
  ],
107
113
  },
114
+ "workout-library": {
115
+ summary: "Search the TrainerRoad workout library with agent-friendly filters (private mode).",
116
+ usage: [
117
+ 'node src/cli.mjs workout-library [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--limit <n>] [--page-size <n>] [--json|--jsonl]',
118
+ ],
119
+ },
120
+ "workout-recommend": {
121
+ summary: "Recommend library workouts by ranking candidates against target duration/level/TSS (private mode).",
122
+ usage: [
123
+ 'node src/cli.mjs workout-recommend [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--target-duration <minutes>] [--target-tss <n>] [--target-level <n>] [--count <n>] [--candidate-limit <n>] [--page-size <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--json|--jsonl]',
124
+ ],
125
+ },
126
+ "workout-details": {
127
+ summary: "Fetch detailed workout-library metadata for a workout ID (private mode).",
128
+ usage: [
129
+ "node src/cli.mjs workout-details --id <workout-id> [--include-chart true|false] [--chart-point-limit <n>] [--json|--jsonl]",
130
+ ],
131
+ },
132
+ "add-workout": {
133
+ summary: "Add a library workout to the calendar on a target date (private mode; reconciles flaky API responses).",
134
+ usage: [
135
+ "node src/cli.mjs add-workout --workout-id <workout-id> --date YYYY-MM-DD [--outside true|false] [--json|--jsonl]",
136
+ ],
137
+ },
138
+ "copy-workout": {
139
+ summary: "Copy an existing planned workout to another date (private mode).",
140
+ usage: [
141
+ "node src/cli.mjs copy-workout --id <planned-activity-id> --date YYYY-MM-DD [--json|--jsonl]",
142
+ ],
143
+ },
144
+ "workout-alternates": {
145
+ summary: "List alternate workout options for a planned workout (private mode).",
146
+ usage: [
147
+ "node src/cli.mjs workout-alternates --id <planned-activity-id> [--category similar|easier|harder|longer|shorter] [--json|--jsonl]",
148
+ ],
149
+ },
150
+ "move-workout": {
151
+ summary: "Move a planned workout to a different date (private mode).",
152
+ usage: [
153
+ "node src/cli.mjs move-workout --id <planned-activity-id> --to YYYY-MM-DD [--json|--jsonl]",
154
+ ],
155
+ },
156
+ "replace-workout": {
157
+ summary: "Replace a planned workout with a specific alternate workout ID (private mode).",
158
+ usage: [
159
+ "node src/cli.mjs replace-workout --id <planned-activity-id> --alternate-id <workout-id> [--update-duration true|false] [--json|--jsonl]",
160
+ ],
161
+ },
162
+ "switch-workout": {
163
+ summary: "Switch a planned workout between inside and outside variants (private mode).",
164
+ usage: [
165
+ "node src/cli.mjs switch-workout --id <planned-activity-id> --mode inside|outside [--json|--jsonl]",
166
+ ],
167
+ },
108
168
  logout: {
109
169
  summary: "Clear local persisted session.",
110
170
  usage: ["node src/cli.mjs logout"],
@@ -212,6 +272,14 @@ export const COMMAND_FLAG_ALLOWLIST = {
212
272
  SHARED_FLAGS.publicProfile,
213
273
  ["full"],
214
274
  ),
275
+ "train-now": mergeFlagGroups(
276
+ SHARED_FLAGS.help,
277
+ SHARED_FLAGS.output,
278
+ SHARED_FLAGS.jsonAndJsonl,
279
+ SHARED_FLAGS.session,
280
+ SHARED_FLAGS.credentials,
281
+ ["duration", "num-suggestions", "category"],
282
+ ),
215
283
  events: mergeFlagGroups(
216
284
  SHARED_FLAGS.help,
217
285
  SHARED_FLAGS.output,
@@ -324,5 +392,115 @@ export const COMMAND_FLAG_ALLOWLIST = {
324
392
  SHARED_FLAGS.credentials,
325
393
  ["start-date", "end-date", "row-type", "indoor-only", "slot", "limit", "full"],
326
394
  ),
395
+ "workout-library": mergeFlagGroups(
396
+ SHARED_FLAGS.help,
397
+ SHARED_FLAGS.output,
398
+ SHARED_FLAGS.jsonAndJsonl,
399
+ SHARED_FLAGS.session,
400
+ SHARED_FLAGS.credentials,
401
+ [
402
+ "search",
403
+ "zone",
404
+ "zone-id",
405
+ "profile",
406
+ "profile-id",
407
+ "outside",
408
+ "has-instructions",
409
+ "min-duration",
410
+ "max-duration",
411
+ "min-tss",
412
+ "max-tss",
413
+ "min-level",
414
+ "max-level",
415
+ "sort",
416
+ "limit",
417
+ "page-size",
418
+ ],
419
+ ),
420
+ "workout-recommend": mergeFlagGroups(
421
+ SHARED_FLAGS.help,
422
+ SHARED_FLAGS.output,
423
+ SHARED_FLAGS.jsonAndJsonl,
424
+ SHARED_FLAGS.session,
425
+ SHARED_FLAGS.credentials,
426
+ [
427
+ "search",
428
+ "zone",
429
+ "zone-id",
430
+ "profile",
431
+ "profile-id",
432
+ "outside",
433
+ "has-instructions",
434
+ "min-duration",
435
+ "max-duration",
436
+ "min-tss",
437
+ "max-tss",
438
+ "min-level",
439
+ "max-level",
440
+ "target-duration",
441
+ "target-tss",
442
+ "target-level",
443
+ "count",
444
+ "candidate-limit",
445
+ "page-size",
446
+ "sort",
447
+ ],
448
+ ),
449
+ "workout-details": mergeFlagGroups(
450
+ SHARED_FLAGS.help,
451
+ SHARED_FLAGS.output,
452
+ SHARED_FLAGS.jsonAndJsonl,
453
+ SHARED_FLAGS.session,
454
+ SHARED_FLAGS.credentials,
455
+ ["id", "include-chart", "chart-point-limit"],
456
+ ),
457
+ "add-workout": mergeFlagGroups(
458
+ SHARED_FLAGS.help,
459
+ SHARED_FLAGS.output,
460
+ SHARED_FLAGS.jsonAndJsonl,
461
+ SHARED_FLAGS.session,
462
+ SHARED_FLAGS.credentials,
463
+ ["workout-id", "date", "outside"],
464
+ ),
465
+ "copy-workout": mergeFlagGroups(
466
+ SHARED_FLAGS.help,
467
+ SHARED_FLAGS.output,
468
+ SHARED_FLAGS.jsonAndJsonl,
469
+ SHARED_FLAGS.session,
470
+ SHARED_FLAGS.credentials,
471
+ ["id", "date"],
472
+ ),
473
+ "workout-alternates": mergeFlagGroups(
474
+ SHARED_FLAGS.help,
475
+ SHARED_FLAGS.output,
476
+ SHARED_FLAGS.jsonAndJsonl,
477
+ SHARED_FLAGS.session,
478
+ SHARED_FLAGS.credentials,
479
+ ["id", "category"],
480
+ ),
481
+ "move-workout": mergeFlagGroups(
482
+ SHARED_FLAGS.help,
483
+ SHARED_FLAGS.output,
484
+ SHARED_FLAGS.jsonAndJsonl,
485
+ SHARED_FLAGS.session,
486
+ SHARED_FLAGS.credentials,
487
+ ["id", "to"],
488
+ ),
489
+ "replace-workout": mergeFlagGroups(
490
+ SHARED_FLAGS.help,
491
+ SHARED_FLAGS.output,
492
+ SHARED_FLAGS.jsonAndJsonl,
493
+ SHARED_FLAGS.session,
494
+ SHARED_FLAGS.credentials,
495
+ ["id", "alternate-id", "update-duration"],
496
+ ),
497
+ "switch-workout": mergeFlagGroups(
498
+ SHARED_FLAGS.help,
499
+ SHARED_FLAGS.output,
500
+ SHARED_FLAGS.jsonAndJsonl,
501
+ SHARED_FLAGS.session,
502
+ SHARED_FLAGS.credentials,
503
+ ["id", "mode"],
504
+ ),
327
505
  logout: mergeFlagGroups(SHARED_FLAGS.help, SHARED_FLAGS.output, SHARED_FLAGS.session),
328
506
  };