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.
package/README.md CHANGED
@@ -14,6 +14,19 @@ 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
32
  ### Run without install (npx)
@@ -64,9 +77,36 @@ trainerroad-cli plan --view current --json
64
77
  trainerroad-cli levels --json
65
78
  trainerroad-cli ftp --json
66
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
67
94
  ```
68
95
 
69
- 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
70
110
 
71
111
  ```bash
72
112
  trainerroad-cli help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trainerroad-cli",
3
- "version": "0.1.1",
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,7 +31,17 @@ 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";
36
46
  import {
37
47
  formatDateTimeInTimeZone,
@@ -67,6 +77,14 @@ function printGlobalHelp() {
67
77
  console.log(" node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
68
78
  console.log(" node src/cli.mjs timeline --target quinnsprouse --public --json");
69
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");
70
88
  }
71
89
 
72
90
  function levenshteinDistance(left, right) {
@@ -650,6 +668,9 @@ async function main() {
650
668
  case "timeline":
651
669
  await commandTimeline(flags, commandDeps);
652
670
  return;
671
+ case "train-now":
672
+ await commandTrainNow(flags, commandDeps);
673
+ return;
653
674
  case "events":
654
675
  await commandEvents(flags, commandDeps);
655
676
  return;
@@ -686,6 +707,33 @@ async function main() {
686
707
  case "power-records":
687
708
  await commandPowerRecords(flags, commandDeps);
688
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;
689
737
  case "logout":
690
738
  await commandLogout(flags, commandDeps);
691
739
  return;
@@ -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
+ }