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 +41 -1
- package/package.json +11 -1
- package/src/cli.mjs +48 -0
- package/src/commands/train-now.mjs +142 -0
- package/src/commands/workout-library.mjs +430 -0
- package/src/commands/workout-mutations.mjs +230 -0
- package/src/commands/workout-recommend.mjs +175 -0
- package/src/commands/workout-tools.mjs +326 -0
- package/src/lib/command-manifest.mjs +178 -0
- package/src/trainerroad-client.mjs +294 -0
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
|
-
|
|
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.
|
|
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(/ /gi, " ")
|
|
5
|
+
.replace(/&/gi, "&")
|
|
6
|
+
.replace(/"/gi, '"')
|
|
7
|
+
.replace(/'/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
|
+
}
|