trainerroad-cli 0.1.0 → 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.
package/README.md CHANGED
@@ -14,28 +14,48 @@ CLI to fetch TrainerRoad data for your account, including:
14
14
  - power ranking and power records
15
15
  - weight history
16
16
 
17
+ It can also perform a small set of verified calendar writes for planned workouts:
18
+
19
+ - search the workout library by zone/profile/search text/duration/level
20
+ - fetch AI suggested workouts from TrainNow
21
+ - recommend library workouts against target duration/level/TSS
22
+ - fetch workout-library details by workout ID
23
+ - add a library workout to a calendar date
24
+ - copy an existing planned workout to another date
25
+ - move a planned workout to a new date
26
+ - list TrainerRoad alternate workout options
27
+ - replace a workout with a specific alternate
28
+ - switch a workout between inside and outside
29
+
17
30
  ## Install
18
31
 
19
- ### From GitHub
32
+ ### Run without install (npx)
20
33
 
21
34
  ```bash
22
- npx --yes github:quinnsprouse/trainerroad-cli help
35
+ npx --yes trainerroad-cli help
23
36
  ```
24
37
 
25
- ### Local development
38
+ ### Global install
26
39
 
27
40
  ```bash
28
- git clone https://github.com/quinnsprouse/trainerroad-cli.git
29
- cd trainerroad-cli
30
- npm install
31
- npm exec --yes trainerroad-cli -- help
41
+ npm install -g trainerroad-cli
42
+ trainerroad-cli help
32
43
  ```
33
44
 
34
- ### Global
45
+ ### Local project install
35
46
 
36
47
  ```bash
37
- npm install -g github:quinnsprouse/trainerroad-cli
38
- trainerroad-cli help
48
+ npm install trainerroad-cli
49
+ npx trainerroad-cli help
50
+ ```
51
+
52
+ ### Local development (from source)
53
+
54
+ ```bash
55
+ git clone https://github.com/quinnsprouse/trainerroad-cli.git
56
+ cd trainerroad-cli
57
+ npm install
58
+ npm run help
39
59
  ```
40
60
 
41
61
  ## Quickstart
@@ -56,9 +76,37 @@ trainerroad-cli past --days 30 --json
56
76
  trainerroad-cli plan --view current --json
57
77
  trainerroad-cli levels --json
58
78
  trainerroad-cli ftp --json
79
+ trainerroad-cli today --tz America/New_York --json
80
+ trainerroad-cli train-now --duration 60 --json
81
+ trainerroad-cli workout-library --zone "Endurance" --profile "Sustained Power" --min-duration 45 --max-duration 75 --json
82
+ trainerroad-cli workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json
83
+ trainerroad-cli workout-details --id 18128 --include-chart --json
84
+ trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --json
85
+ trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
86
+ ```
87
+
88
+ 3. Mutate planned workouts
89
+
90
+ First get a planned workout ID from `future --details`:
91
+
92
+ ```bash
93
+ trainerroad-cli future --days 14 --details --json
59
94
  ```
60
95
 
61
- 3. Discover all commands
96
+ Then use that planned activity ID:
97
+
98
+ ```bash
99
+ trainerroad-cli workout-alternates --id <planned-activity-id> --category easier --json
100
+ trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --json
101
+ trainerroad-cli replace-workout --id <planned-activity-id> --alternate-id <workout-id> --json
102
+ trainerroad-cli switch-workout --id <planned-activity-id> --mode outside --json
103
+ trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
104
+ ```
105
+
106
+ `copy-workout` is the reliable way to place an existing planned workout on another date.
107
+ `add-workout` exists, but TrainerRoad's add endpoints are still inconsistent and may fail even after retry/reconciliation.
108
+
109
+ 4. Discover all commands
62
110
 
63
111
  ```bash
64
112
  trainerroad-cli help
@@ -80,6 +128,7 @@ Use `--target <username>` and/or `--public` for public mode queries.
80
128
  - `--jsonl`: one record per line
81
129
  - `--fields a,b,c`: project record fields
82
130
  - `--records-only`: lighter record payloads
131
+ - `--tz <IANA timezone>`: localize day boundaries/timestamps (defaults to `TR_TIMEZONE` or system timezone)
83
132
 
84
133
  ## Security
85
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trainerroad-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Unofficial CLI for authenticating with TrainerRoad and querying timeline/workout data",
5
5
  "main": "src/cli.mjs",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "login": "node src/cli.mjs login",
22
22
  "whoami": "node src/cli.mjs whoami",
23
23
  "timeline": "node src/cli.mjs timeline",
24
+ "train-now": "node src/cli.mjs train-now",
24
25
  "events": "node src/cli.mjs events",
25
26
  "annotations": "node src/cli.mjs annotations",
26
27
  "levels": "node src/cli.mjs levels",
@@ -33,6 +34,15 @@
33
34
  "ftp-prediction": "node src/cli.mjs ftp-prediction",
34
35
  "power-ranking": "node src/cli.mjs power-ranking",
35
36
  "power-records": "node src/cli.mjs power-records",
37
+ "workout-library": "node src/cli.mjs workout-library",
38
+ "workout-recommend": "node src/cli.mjs workout-recommend",
39
+ "workout-details": "node src/cli.mjs workout-details",
40
+ "add-workout": "node src/cli.mjs add-workout",
41
+ "copy-workout": "node src/cli.mjs copy-workout",
42
+ "workout-alternates": "node src/cli.mjs workout-alternates",
43
+ "move-workout": "node src/cli.mjs move-workout",
44
+ "replace-workout": "node src/cli.mjs replace-workout",
45
+ "switch-workout": "node src/cli.mjs switch-workout",
36
46
  "logout": "node src/cli.mjs logout"
37
47
  },
38
48
  "keywords": [
package/src/cli.mjs CHANGED
@@ -31,8 +31,28 @@ import { commandLevels } from "./commands/levels.mjs";
31
31
  import { commandPlan } from "./commands/plan.mjs";
32
32
  import { commandPowerRanking, commandPowerRecords } from "./commands/power.mjs";
33
33
  import { commandTimeline } from "./commands/timeline.mjs";
34
+ import { commandTrainNow } from "./commands/train-now.mjs";
34
35
  import { commandWeightHistory } from "./commands/weight-history.mjs";
36
+ import { commandWorkoutLibrary } from "./commands/workout-library.mjs";
37
+ import { commandWorkoutRecommend } from "./commands/workout-recommend.mjs";
38
+ import { commandAddWorkout, commandCopyWorkout, commandWorkoutDetails } from "./commands/workout-tools.mjs";
39
+ import {
40
+ commandMoveWorkout,
41
+ commandReplaceWorkout,
42
+ commandSwitchWorkout,
43
+ commandWorkoutAlternates,
44
+ } from "./commands/workout-mutations.mjs";
35
45
  import { commandFuture, commandPast, commandToday } from "./commands/workouts.mjs";
46
+ import {
47
+ formatDateTimeInTimeZone,
48
+ isoDateShiftInTimeZone,
49
+ normalizeTimeZone,
50
+ parseApiDateTime,
51
+ summarizeActivityTimeWindow,
52
+ toDateOnlyInTimeZone,
53
+ } from "./lib/timezone.mjs";
54
+
55
+ let ACTIVE_TIME_ZONE = normalizeTimeZone();
36
56
 
37
57
  function printGlobalHelp() {
38
58
  console.log("trainerroad-cli (unofficial)");
@@ -53,9 +73,18 @@ function printGlobalHelp() {
53
73
  console.log("Examples:");
54
74
  console.log(" node src/cli.mjs login --username quinnsprouse --password-stdin");
55
75
  console.log(" node src/cli.mjs future --days 30 --details --json");
76
+ console.log(" node src/cli.mjs today --tz America/New_York --json");
56
77
  console.log(" node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
57
78
  console.log(" node src/cli.mjs timeline --target quinnsprouse --public --json");
58
79
  console.log(" node src/cli.mjs ftp --target quinnsprouse --public --json");
80
+ console.log(" node src/cli.mjs move-workout --id <planned-id> --to 2026-03-13 --json");
81
+ console.log(" node src/cli.mjs workout-alternates --id <planned-id> --category easier --json");
82
+ console.log(' node src/cli.mjs workout-library --zone "Endurance" --profile "Sustained Power" --min-duration 45 --max-duration 75 --json');
83
+ console.log(' node src/cli.mjs workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json');
84
+ console.log(" node src/cli.mjs train-now --duration 60 --json");
85
+ console.log(" node src/cli.mjs workout-details --id 18128 --include-chart --json");
86
+ console.log(" node src/cli.mjs add-workout --workout-id 18128 --date 2026-03-16 --json");
87
+ console.log(" node src/cli.mjs copy-workout --id <planned-id> --date 2026-03-16 --json");
59
88
  }
60
89
 
61
90
  function levenshteinDistance(left, right) {
@@ -179,6 +208,7 @@ function printCommandHelp(command, flags = {}) {
179
208
  command,
180
209
  summary: def.summary,
181
210
  usage: def.usage,
211
+ timezoneOption: "--tz <IANA timezone> (defaults to TR_TIMEZONE or system timezone)",
182
212
  supportsAgentFilters: FILTERABLE_COMMANDS.has(command),
183
213
  agentFilterOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_FILTER_OPTIONS : [],
184
214
  agentOutputOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_OUTPUT_OPTIONS : [],
@@ -194,6 +224,9 @@ function printCommandHelp(command, flags = {}) {
194
224
  console.log("");
195
225
  console.log("Usage:");
196
226
  for (const line of def.usage) console.log(` ${line}`);
227
+ console.log("");
228
+ console.log("Timezone:");
229
+ console.log(" --tz <IANA timezone> Override local-day bucketing (defaults: TR_TIMEZONE or system timezone).");
197
230
  if (FILTERABLE_COMMANDS.has(command)) {
198
231
  console.log("");
199
232
  console.log("Agent filters:");
@@ -234,9 +267,7 @@ function parseArgs(argv) {
234
267
  }
235
268
 
236
269
  function isoDateShift(days) {
237
- const date = new Date();
238
- date.setUTCDate(date.getUTCDate() + days);
239
- return date.toISOString().slice(0, 10);
270
+ return isoDateShiftInTimeZone(days, ACTIVE_TIME_ZONE);
240
271
  }
241
272
 
242
273
  function toIsoDateFromPlanned(item) {
@@ -244,8 +275,26 @@ function toIsoDateFromPlanned(item) {
244
275
  }
245
276
 
246
277
  function toIsoDate(value) {
247
- if (typeof value === "string" && value.length >= 10) return value.slice(0, 10);
248
- return new Date(value).toISOString().slice(0, 10);
278
+ if (typeof value === "string" && value.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(value)) {
279
+ return value.slice(0, 10);
280
+ }
281
+ return (
282
+ toDateOnlyInTimeZone(value, ACTIVE_TIME_ZONE, { assumeUtcForOffsetlessDateTime: true }) ??
283
+ new Date(value).toISOString().slice(0, 10)
284
+ );
285
+ }
286
+
287
+ function formatDateTime(value) {
288
+ return (
289
+ formatDateTimeInTimeZone(value, ACTIVE_TIME_ZONE, { assumeUtcForOffsetlessDateTime: true }) ??
290
+ String(value ?? "")
291
+ );
292
+ }
293
+
294
+ function summarizeActivityTime(started, durationInSeconds) {
295
+ return summarizeActivityTimeWindow(started, durationInSeconds, ACTIVE_TIME_ZONE, {
296
+ assumeUtcForOffsetlessDateTime: true,
297
+ });
249
298
  }
250
299
 
251
300
  function normalizeDateOnlyInput(value, fallback) {
@@ -292,8 +341,10 @@ function normalizeFtpHistory(raw) {
292
341
  const valueRaw = item?.value ?? item?.Value ?? null;
293
342
  const value = Number(valueRaw);
294
343
  if (!dateRaw || !Number.isFinite(value)) return null;
344
+ const parsedDate = parseApiDateTime(dateRaw, { assumeUtcForOffsetlessDateTime: true });
345
+ if (!parsedDate) return null;
295
346
  return {
296
- date: new Date(dateRaw).toISOString(),
347
+ date: parsedDate.toISOString(),
297
348
  dateOnly: toIsoDate(dateRaw),
298
349
  value,
299
350
  };
@@ -328,9 +379,11 @@ function normalizeFitnessThresholds(raw) {
328
379
  const valueRaw = item?.value ?? item?.Value ?? null;
329
380
  const value = Number(valueRaw);
330
381
  if (!dateRaw || !Number.isFinite(value)) return null;
382
+ const parsedDate = parseApiDateTime(dateRaw, { assumeUtcForOffsetlessDateTime: true });
383
+ if (!parsedDate) return null;
331
384
  return {
332
385
  id: item?.id ?? item?.Id ?? null,
333
- date: new Date(dateRaw).toISOString(),
386
+ date: parsedDate.toISOString(),
334
387
  dateOnly: toIsoDate(dateRaw),
335
388
  value,
336
389
  isApplied: Boolean(item?.isApplied ?? item?.IsApplied),
@@ -397,9 +450,21 @@ async function readPasswordFromStdin() {
397
450
  return Buffer.concat(chunks).toString("utf8").trim();
398
451
  }
399
452
 
453
+ function withTimeZoneMeta(payload) {
454
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
455
+ if (payload.timeZone != null) return payload;
456
+ return { ...payload, timeZone: ACTIVE_TIME_ZONE };
457
+ }
458
+
400
459
  async function writeOutput(payload, flags, textRenderer = null) {
460
+ const payloadWithTimeZone = withTimeZoneMeta(payload);
461
+
401
462
  if (flags.jsonl) {
402
- const records = Array.isArray(payload?.records) ? payload.records : Array.isArray(payload) ? payload : [];
463
+ const records = Array.isArray(payloadWithTimeZone?.records)
464
+ ? payloadWithTimeZone.records
465
+ : Array.isArray(payloadWithTimeZone)
466
+ ? payloadWithTimeZone
467
+ : [];
403
468
  const content = records.map((item) => JSON.stringify(item)).join("\n");
404
469
  if (flags.output) {
405
470
  await fs.writeFile(flags.output, `${content}${content ? "\n" : ""}`, "utf8");
@@ -410,9 +475,11 @@ async function writeOutput(payload, flags, textRenderer = null) {
410
475
  return;
411
476
  }
412
477
 
413
- if (flags.json || typeof payload !== "string") {
478
+ if (flags.json || typeof payloadWithTimeZone !== "string") {
414
479
  const content =
415
- typeof payload === "string" ? payload : `${JSON.stringify(payload, null, 2)}\n`;
480
+ typeof payloadWithTimeZone === "string"
481
+ ? payloadWithTimeZone
482
+ : `${JSON.stringify(payloadWithTimeZone, null, 2)}\n`;
416
483
  if (flags.output) {
417
484
  await fs.writeFile(flags.output, content, "utf8");
418
485
  console.log(`Wrote JSON to ${flags.output}`);
@@ -422,7 +489,7 @@ async function writeOutput(payload, flags, textRenderer = null) {
422
489
  return;
423
490
  }
424
491
 
425
- const text = textRenderer ? textRenderer(payload) : String(payload);
492
+ const text = textRenderer ? textRenderer(payloadWithTimeZone) : String(payloadWithTimeZone);
426
493
  if (flags.output) {
427
494
  await fs.writeFile(flags.output, `${text}\n`, "utf8");
428
495
  console.log(`Wrote text to ${flags.output}`);
@@ -532,6 +599,9 @@ async function main() {
532
599
  process.exit(1);
533
600
  }
534
601
 
602
+ ACTIVE_TIME_ZONE = normalizeTimeZone(flags.tz ?? null);
603
+ process.env.TR_TIMEZONE = ACTIVE_TIME_ZONE;
604
+
535
605
  if (command === "help") {
536
606
  if (positionals[0]) {
537
607
  process.exit(printCommandHelp(positionals[0], flags));
@@ -550,6 +620,7 @@ async function main() {
550
620
  }
551
621
 
552
622
  const commandDeps = {
623
+ timeZone: ACTIVE_TIME_ZONE,
553
624
  resolveQueryContext,
554
625
  requirePrivateContext,
555
626
  applyAgentRecordFilters,
@@ -562,11 +633,14 @@ async function main() {
562
633
  normalizeDateOnlyInput,
563
634
  isoDateShift,
564
635
  filterFuturePlanned,
565
- filterPastActivities,
636
+ filterPastActivities: (activities, fromDateIso, toDateIso) =>
637
+ filterPastActivities(activities, fromDateIso, toDateIso, ACTIVE_TIME_ZONE),
566
638
  sortByDateAsc,
567
639
  sortByDateDesc,
568
640
  toIsoDateFromPlanned,
569
641
  toIsoDate,
642
+ formatDateTime,
643
+ summarizeActivityTime,
570
644
  withClient,
571
645
  readPasswordFromStdin,
572
646
  normalizeFtpHistory,
@@ -594,6 +668,9 @@ async function main() {
594
668
  case "timeline":
595
669
  await commandTimeline(flags, commandDeps);
596
670
  return;
671
+ case "train-now":
672
+ await commandTrainNow(flags, commandDeps);
673
+ return;
597
674
  case "events":
598
675
  await commandEvents(flags, commandDeps);
599
676
  return;
@@ -630,6 +707,33 @@ async function main() {
630
707
  case "power-records":
631
708
  await commandPowerRecords(flags, commandDeps);
632
709
  return;
710
+ case "workout-library":
711
+ await commandWorkoutLibrary(flags, commandDeps);
712
+ return;
713
+ case "workout-recommend":
714
+ await commandWorkoutRecommend(flags, commandDeps);
715
+ return;
716
+ case "workout-details":
717
+ await commandWorkoutDetails(flags, commandDeps);
718
+ return;
719
+ case "add-workout":
720
+ await commandAddWorkout(flags, commandDeps);
721
+ return;
722
+ case "copy-workout":
723
+ await commandCopyWorkout(flags, commandDeps);
724
+ return;
725
+ case "workout-alternates":
726
+ await commandWorkoutAlternates(flags, commandDeps);
727
+ return;
728
+ case "move-workout":
729
+ await commandMoveWorkout(flags, commandDeps);
730
+ return;
731
+ case "replace-workout":
732
+ await commandReplaceWorkout(flags, commandDeps);
733
+ return;
734
+ case "switch-workout":
735
+ await commandSwitchWorkout(flags, commandDeps);
736
+ return;
633
737
  case "logout":
634
738
  await commandLogout(flags, commandDeps);
635
739
  return;
@@ -667,6 +771,9 @@ main().catch((error) => {
667
771
  if (message.includes('Invalid date "')) {
668
772
  console.error("Tip: expected date format is YYYY-MM-DD");
669
773
  }
774
+ if (message.includes('Invalid timezone "')) {
775
+ console.error("Tip: use an IANA timezone like America/New_York.");
776
+ }
670
777
  console.error('Run "trainerroad-cli help" or "trainerroad-cli help <command>" for usage.');
671
778
 
672
779
  if (process.env.TR_CLI_DEBUG === "1" && error?.stack) {
@@ -30,6 +30,7 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
30
30
  "node src/cli.mjs capabilities --json",
31
31
  "node src/cli.mjs whoami --json",
32
32
  "node src/cli.mjs future --days 30 --json",
33
+ "node src/cli.mjs today --tz America/New_York --json",
33
34
  "node src/cli.mjs help future --json",
34
35
  ],
35
36
  commandCount: commandEntries.length,
@@ -152,6 +153,11 @@ export async function commandCapabilities(flags, deps) {
152
153
  outputOptions: AGENT_OUTPUT_OPTIONS,
153
154
  },
154
155
  outputModes: ["text", "json", "jsonl"],
156
+ timezone: {
157
+ flag: "--tz <IANA timezone>",
158
+ environment: "TR_TIMEZONE",
159
+ example: "America/New_York",
160
+ },
155
161
  };
156
162
 
157
163
  await writeOutput(payload, flags, () => {
@@ -160,6 +166,7 @@ export async function commandCapabilities(flags, deps) {
160
166
  "- Private mode (authenticated): full timeline + workout details.",
161
167
  "- Public mode (unauthenticated): day-level TSS/ride/planned signals + FTP history.",
162
168
  "- Commands support both via automatic mode selection and --target/--public flags.",
169
+ "- Timezone-aware date bucketing: use --tz or TR_TIMEZONE for local-day accuracy.",
163
170
  ].join("\n");
164
171
  });
165
172
  }
@@ -0,0 +1,142 @@
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
+ const CATEGORY_NAME_MAP = {
13
+ climbing: "Climbing",
14
+ endurance: "Endurance",
15
+ attacking: "Attacking",
16
+ };
17
+
18
+ function normalizeCategoryName(value) {
19
+ const normalized = String(value ?? "").trim().toLowerCase();
20
+ return CATEGORY_NAME_MAP[normalized] ?? null;
21
+ }
22
+
23
+ function summarizeWorkoutInfo(info) {
24
+ if (!info || typeof info !== "object") return null;
25
+ return {
26
+ workoutId: info.id ?? null,
27
+ workoutName: info.name ?? null,
28
+ duration: info.duration ?? null,
29
+ durationMinutes:
30
+ Number.isFinite(Number(info.durationInSeconds)) ? Math.round(Number(info.durationInSeconds) / 60) : null,
31
+ tss: info.tss ?? null,
32
+ intensityFactor: info.intensityFactor ?? null,
33
+ energyKj: info.kj ?? null,
34
+ zoneId: info.progressionId ?? null,
35
+ progressionLevel: info.progressionLevel ?? null,
36
+ workoutDifficultyRating: info.workoutDifficultyRating ?? null,
37
+ isOutside: info.isOutside ?? null,
38
+ profileId: info.profileId ?? null,
39
+ imageUrl: info.picUrl ?? null,
40
+ };
41
+ }
42
+
43
+ function summarizeSuggestion(categoryName, suggestion, workoutInfo) {
44
+ return {
45
+ category: categoryName,
46
+ source: suggestion?.source ?? null,
47
+ workoutId: suggestion?.workoutId ?? workoutInfo?.workoutId ?? null,
48
+ ...workoutInfo,
49
+ predictionMetrics: suggestion?.predictionMetrics ?? {},
50
+ };
51
+ }
52
+
53
+ function flattenSuggestions(suggestions, workoutInfoById, categoryFilter = null) {
54
+ const categories = suggestions && typeof suggestions === "object" ? Object.entries(suggestions) : [];
55
+ const rows = [];
56
+ for (const [categoryName, items] of categories) {
57
+ if (categoryFilter && categoryName !== categoryFilter) continue;
58
+ for (const item of Array.isArray(items) ? items : []) {
59
+ rows.push(
60
+ summarizeSuggestion(
61
+ categoryName,
62
+ item,
63
+ workoutInfoById.get(Number(item?.workoutId)) ?? null,
64
+ ),
65
+ );
66
+ }
67
+ }
68
+ return rows;
69
+ }
70
+
71
+ async function requirePrivateMember(flags, deps) {
72
+ const { withClient } = deps;
73
+ const client = await withClient(flags);
74
+ try {
75
+ const memberInfo = await client.getMemberInfo();
76
+ return { client, memberInfo };
77
+ } catch {
78
+ throw new Error("train-now requires private authenticated mode. Login first with trainerroad-cli login.");
79
+ }
80
+ }
81
+
82
+ export async function commandTrainNow(flags, deps) {
83
+ const { isJsonMode, requirePositiveInteger, writeOutput } = deps;
84
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
85
+
86
+ const duration = requirePositiveInteger(flags.duration, 60);
87
+ const numSuggestions = requirePositiveInteger(flags["num-suggestions"], 10);
88
+ const categoryFilter = flags.category ? normalizeCategoryName(flags.category) : null;
89
+ if (flags.category && !categoryFilter) {
90
+ throw new Error('Invalid --category. Expected one of: climbing, endurance, attacking.');
91
+ }
92
+
93
+ const [status, suggestionsPayload] = await Promise.all([
94
+ client.getTrainNowStatus(memberInfo.username),
95
+ client.getTrainNowSuggestions({ duration, numSuggestions }, memberInfo.username),
96
+ ]);
97
+
98
+ const suggestionIds = Array.from(
99
+ new Set(
100
+ Object.values(suggestionsPayload?.suggestions ?? {})
101
+ .flatMap((items) => (Array.isArray(items) ? items : []))
102
+ .map((item) => Number(item?.workoutId))
103
+ .filter((value) => Number.isFinite(value)),
104
+ ),
105
+ );
106
+ const workoutInfo = await client.getWorkoutInformation(suggestionIds, memberInfo.username);
107
+ const workoutInfoById = new Map(
108
+ (Array.isArray(workoutInfo) ? workoutInfo : [])
109
+ .map((item) => [Number(item?.id), summarizeWorkoutInfo(item)])
110
+ .filter(([id]) => Number.isFinite(id)),
111
+ );
112
+
113
+ const records = flattenSuggestions(suggestionsPayload?.suggestions, workoutInfoById, categoryFilter);
114
+ const payload = {
115
+ generatedAt: new Date().toISOString(),
116
+ command: "train-now",
117
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
118
+ query: { duration, numSuggestions, category: categoryFilter },
119
+ status,
120
+ recommendedCategory: suggestionsPayload?.recommendedCategory ?? null,
121
+ hasRpePredictionServiceFailure: Boolean(suggestionsPayload?.hasRpePredictionServiceFailure),
122
+ count: records.length,
123
+ records,
124
+ };
125
+
126
+ if (!isJsonMode(flags)) {
127
+ await writeOutput(payload, flags, (value) => {
128
+ const lines = [
129
+ `TrainNow suggestions (${value.count}) duration=${value.query.duration}m`,
130
+ ];
131
+ for (const item of value.records) {
132
+ lines.push(
133
+ `- [${item.category}] ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"} | level=${item.progressionLevel ?? "?"}`,
134
+ );
135
+ }
136
+ return lines.join("\n");
137
+ });
138
+ return;
139
+ }
140
+
141
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
142
+ }