trainerroad-cli 0.1.1 → 0.3.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/CHANGELOG.md +10 -0
- package/README.md +60 -1
- package/package.json +13 -1
- package/src/cli.mjs +121 -21
- package/src/commands/auth.mjs +3 -2
- package/src/commands/discovery.mjs +9 -8
- package/src/commands/train-now.mjs +142 -0
- package/src/commands/workout-library.mjs +430 -0
- package/src/commands/workout-mutations.mjs +310 -0
- package/src/commands/workout-recommend.mjs +175 -0
- package/src/commands/workout-tools.mjs +388 -0
- package/src/lib/command-manifest.mjs +465 -25
- package/src/trainerroad-client.mjs +295 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 - 2026-03-25
|
|
4
|
+
|
|
5
|
+
- Added richer command help with examples, required-flag metadata, and machine-readable help payloads.
|
|
6
|
+
- Improved agent-facing failure messages so missing required flags fail fast with actionable retry guidance.
|
|
7
|
+
- Added `--dry-run` previews for calendar mutation commands.
|
|
8
|
+
- Made convergent mutation commands idempotent when the requested end state is already satisfied.
|
|
9
|
+
- Fixed login default return-path handling so it follows the provided username.
|
|
10
|
+
- Added automated tests covering CLI help/error behavior and write-command dry-run/no-op semantics.
|
package/README.md
CHANGED
|
@@ -14,6 +14,27 @@ 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
|
+
|
|
30
|
+
## Agent-Friendly Behavior
|
|
31
|
+
|
|
32
|
+
- Non-interactive by default. Inputs are flags or stdin, not prompts.
|
|
33
|
+
- Progressive disclosure. Use `trainerroad-cli help <command>` or `trainerroad-cli discover`.
|
|
34
|
+
- Command help includes concrete examples and flag descriptions.
|
|
35
|
+
- Write commands support `--dry-run` previews.
|
|
36
|
+
- Common retry cases are idempotent no-ops instead of duplicate calendar writes.
|
|
37
|
+
|
|
17
38
|
## Install
|
|
18
39
|
|
|
19
40
|
### Run without install (npx)
|
|
@@ -64,9 +85,38 @@ trainerroad-cli plan --view current --json
|
|
|
64
85
|
trainerroad-cli levels --json
|
|
65
86
|
trainerroad-cli ftp --json
|
|
66
87
|
trainerroad-cli today --tz America/New_York --json
|
|
88
|
+
trainerroad-cli train-now --duration 60 --json
|
|
89
|
+
trainerroad-cli workout-library --zone "Endurance" --profile "Sustained Power" --min-duration 45 --max-duration 75 --json
|
|
90
|
+
trainerroad-cli workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json
|
|
91
|
+
trainerroad-cli workout-details --id 18128 --include-chart --json
|
|
92
|
+
trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --json
|
|
93
|
+
trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --dry-run
|
|
94
|
+
trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
3. Mutate planned workouts
|
|
98
|
+
|
|
99
|
+
First get a planned workout ID from `future --details`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
trainerroad-cli future --days 14 --details --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then use that planned activity ID:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
trainerroad-cli workout-alternates --id <planned-activity-id> --category easier --json
|
|
109
|
+
trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --dry-run
|
|
110
|
+
trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --json
|
|
111
|
+
trainerroad-cli replace-workout --id <planned-activity-id> --alternate-id <workout-id> --json
|
|
112
|
+
trainerroad-cli switch-workout --id <planned-activity-id> --mode outside --json
|
|
113
|
+
trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
|
|
67
114
|
```
|
|
68
115
|
|
|
69
|
-
|
|
116
|
+
`copy-workout` is the reliable way to place an existing planned workout on another date.
|
|
117
|
+
`add-workout` exists, but TrainerRoad's add endpoints are still inconsistent and may fail even after retry/reconciliation.
|
|
118
|
+
|
|
119
|
+
4. Discover all commands
|
|
70
120
|
|
|
71
121
|
```bash
|
|
72
122
|
trainerroad-cli help
|
|
@@ -90,6 +140,15 @@ Use `--target <username>` and/or `--public` for public mode queries.
|
|
|
90
140
|
- `--records-only`: lighter record payloads
|
|
91
141
|
- `--tz <IANA timezone>`: localize day boundaries/timestamps (defaults to `TR_TIMEZONE` or system timezone)
|
|
92
142
|
|
|
143
|
+
## Help
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
trainerroad-cli help
|
|
147
|
+
trainerroad-cli help future
|
|
148
|
+
trainerroad-cli future --help
|
|
149
|
+
trainerroad-cli help move-workout --json
|
|
150
|
+
```
|
|
151
|
+
|
|
93
152
|
## Security
|
|
94
153
|
|
|
95
154
|
- Session cookies are stored in `.trainerroad/session.json`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trainerroad-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Unofficial CLI for authenticating with TrainerRoad and querying timeline/workout data",
|
|
5
5
|
"main": "src/cli.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"CHANGELOG.md",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
17
|
+
"test": "node --test",
|
|
16
18
|
"recon": "node scripts/recon.mjs",
|
|
17
19
|
"cli": "node src/cli.mjs",
|
|
18
20
|
"help": "node src/cli.mjs help",
|
|
@@ -21,6 +23,7 @@
|
|
|
21
23
|
"login": "node src/cli.mjs login",
|
|
22
24
|
"whoami": "node src/cli.mjs whoami",
|
|
23
25
|
"timeline": "node src/cli.mjs timeline",
|
|
26
|
+
"train-now": "node src/cli.mjs train-now",
|
|
24
27
|
"events": "node src/cli.mjs events",
|
|
25
28
|
"annotations": "node src/cli.mjs annotations",
|
|
26
29
|
"levels": "node src/cli.mjs levels",
|
|
@@ -33,6 +36,15 @@
|
|
|
33
36
|
"ftp-prediction": "node src/cli.mjs ftp-prediction",
|
|
34
37
|
"power-ranking": "node src/cli.mjs power-ranking",
|
|
35
38
|
"power-records": "node src/cli.mjs power-records",
|
|
39
|
+
"workout-library": "node src/cli.mjs workout-library",
|
|
40
|
+
"workout-recommend": "node src/cli.mjs workout-recommend",
|
|
41
|
+
"workout-details": "node src/cli.mjs workout-details",
|
|
42
|
+
"add-workout": "node src/cli.mjs add-workout",
|
|
43
|
+
"copy-workout": "node src/cli.mjs copy-workout",
|
|
44
|
+
"workout-alternates": "node src/cli.mjs workout-alternates",
|
|
45
|
+
"move-workout": "node src/cli.mjs move-workout",
|
|
46
|
+
"replace-workout": "node src/cli.mjs replace-workout",
|
|
47
|
+
"switch-workout": "node src/cli.mjs switch-workout",
|
|
36
48
|
"logout": "node src/cli.mjs logout"
|
|
37
49
|
},
|
|
38
50
|
"keywords": [
|
package/src/cli.mjs
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
AGENT_FILTER_OPTIONS,
|
|
18
18
|
AGENT_OUTPUT_OPTIONS,
|
|
19
19
|
COMMAND_FLAG_ALLOWLIST,
|
|
20
|
+
COMMAND_REQUIRED_FLAGS,
|
|
20
21
|
COMMANDS,
|
|
22
|
+
FLAG_DETAILS,
|
|
21
23
|
FILTERABLE_COMMANDS,
|
|
22
24
|
GLOBAL_NOTES,
|
|
23
25
|
PROJECT_NOTICE,
|
|
@@ -31,7 +33,17 @@ import { commandLevels } from "./commands/levels.mjs";
|
|
|
31
33
|
import { commandPlan } from "./commands/plan.mjs";
|
|
32
34
|
import { commandPowerRanking, commandPowerRecords } from "./commands/power.mjs";
|
|
33
35
|
import { commandTimeline } from "./commands/timeline.mjs";
|
|
36
|
+
import { commandTrainNow } from "./commands/train-now.mjs";
|
|
34
37
|
import { commandWeightHistory } from "./commands/weight-history.mjs";
|
|
38
|
+
import { commandWorkoutLibrary } from "./commands/workout-library.mjs";
|
|
39
|
+
import { commandWorkoutRecommend } from "./commands/workout-recommend.mjs";
|
|
40
|
+
import { commandAddWorkout, commandCopyWorkout, commandWorkoutDetails } from "./commands/workout-tools.mjs";
|
|
41
|
+
import {
|
|
42
|
+
commandMoveWorkout,
|
|
43
|
+
commandReplaceWorkout,
|
|
44
|
+
commandSwitchWorkout,
|
|
45
|
+
commandWorkoutAlternates,
|
|
46
|
+
} from "./commands/workout-mutations.mjs";
|
|
35
47
|
import { commandFuture, commandPast, commandToday } from "./commands/workouts.mjs";
|
|
36
48
|
import {
|
|
37
49
|
formatDateTimeInTimeZone,
|
|
@@ -56,17 +68,27 @@ function printGlobalHelp() {
|
|
|
56
68
|
for (const note of GLOBAL_NOTES) console.log(` - ${note}`);
|
|
57
69
|
console.log("");
|
|
58
70
|
console.log("Progressive disclosure:");
|
|
59
|
-
console.log("
|
|
60
|
-
console.log("
|
|
61
|
-
console.log("
|
|
71
|
+
console.log(" trainerroad-cli discover --level 1");
|
|
72
|
+
console.log(" trainerroad-cli discover --level 2");
|
|
73
|
+
console.log(" trainerroad-cli discover --command future --level 3 --json");
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log('Use "trainerroad-cli <command> --help" for command-specific options and examples.');
|
|
62
76
|
console.log("");
|
|
63
77
|
console.log("Examples:");
|
|
64
|
-
console.log("
|
|
65
|
-
console.log("
|
|
66
|
-
console.log("
|
|
67
|
-
console.log("
|
|
68
|
-
console.log("
|
|
69
|
-
console.log("
|
|
78
|
+
console.log(" trainerroad-cli login --username quinnsprouse --password-stdin");
|
|
79
|
+
console.log(" trainerroad-cli future --days 30 --details --json");
|
|
80
|
+
console.log(" trainerroad-cli today --tz America/New_York --json");
|
|
81
|
+
console.log(" trainerroad-cli future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
|
|
82
|
+
console.log(" trainerroad-cli timeline --target quinnsprouse --public --json");
|
|
83
|
+
console.log(" trainerroad-cli ftp --target quinnsprouse --public --json");
|
|
84
|
+
console.log(" trainerroad-cli move-workout --id <planned-id> --to 2026-03-13 --dry-run");
|
|
85
|
+
console.log(" trainerroad-cli workout-alternates --id <planned-id> --category easier --json");
|
|
86
|
+
console.log(' trainerroad-cli workout-library --zone "Endurance" --profile "Sustained Power" --min-duration 45 --max-duration 75 --json');
|
|
87
|
+
console.log(' trainerroad-cli workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json');
|
|
88
|
+
console.log(" trainerroad-cli train-now --duration 60 --json");
|
|
89
|
+
console.log(" trainerroad-cli workout-details --id 18128 --include-chart --json");
|
|
90
|
+
console.log(" trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --dry-run");
|
|
91
|
+
console.log(" trainerroad-cli copy-workout --id <planned-id> --date 2026-03-16 --json");
|
|
70
92
|
}
|
|
71
93
|
|
|
72
94
|
function levenshteinDistance(left, right) {
|
|
@@ -179,6 +201,45 @@ function validateCommandFlags(command, flags) {
|
|
|
179
201
|
return { unknownFlags, allowlist: Array.from(allowlist) };
|
|
180
202
|
}
|
|
181
203
|
|
|
204
|
+
function formatFlagLabel(name) {
|
|
205
|
+
const detail = FLAG_DETAILS[name] ?? {};
|
|
206
|
+
return `--${name}${detail.placeholder ? ` ${detail.placeholder}` : ""}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getCommandHelpOptions(command) {
|
|
210
|
+
const allowlist = Array.from(COMMAND_FLAG_ALLOWLIST[command] ?? []);
|
|
211
|
+
const requiredFlags = new Set(COMMAND_REQUIRED_FLAGS[command] ?? []);
|
|
212
|
+
return allowlist.map((name) => ({
|
|
213
|
+
name,
|
|
214
|
+
label: formatFlagLabel(name),
|
|
215
|
+
description: FLAG_DETAILS[name]?.description ?? "No description available.",
|
|
216
|
+
required: requiredFlags.has(name),
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function formatMissingRequiredFlagMessage(command, flagName) {
|
|
221
|
+
const def = COMMANDS[command];
|
|
222
|
+
const requiredFlags = (COMMAND_REQUIRED_FLAGS[command] ?? []).map((name) => `--${name}`);
|
|
223
|
+
const usageLines = Array.isArray(def?.usage) ? def.usage : [];
|
|
224
|
+
const exampleLines = Array.isArray(def?.examples) ? def.examples : [];
|
|
225
|
+
const lines = [`Missing required flag --${flagName} for "trainerroad-cli ${command}".`];
|
|
226
|
+
|
|
227
|
+
if (requiredFlags.length > 0) {
|
|
228
|
+
lines.push("", `Required flags: ${requiredFlags.join(", ")}`);
|
|
229
|
+
}
|
|
230
|
+
if (usageLines.length > 0) {
|
|
231
|
+
lines.push("", "Usage:");
|
|
232
|
+
for (const line of usageLines) lines.push(` ${line}`);
|
|
233
|
+
}
|
|
234
|
+
if (exampleLines.length > 0) {
|
|
235
|
+
lines.push("", "Examples:");
|
|
236
|
+
for (const line of exampleLines) lines.push(` ${line}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
lines.push("", `Run "trainerroad-cli help ${command}" for more detail.`);
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
182
243
|
function printCommandHelp(command, flags = {}) {
|
|
183
244
|
const def = COMMANDS[command];
|
|
184
245
|
if (!def) {
|
|
@@ -186,11 +247,16 @@ function printCommandHelp(command, flags = {}) {
|
|
|
186
247
|
return 1;
|
|
187
248
|
}
|
|
188
249
|
if (flags.json) {
|
|
250
|
+
const options = getCommandHelpOptions(command);
|
|
189
251
|
const payload = {
|
|
190
252
|
command,
|
|
191
253
|
summary: def.summary,
|
|
192
254
|
usage: def.usage,
|
|
193
|
-
|
|
255
|
+
examples: def.examples ?? [],
|
|
256
|
+
options,
|
|
257
|
+
requiredFlags: options.filter((option) => option.required).map((option) => option.name),
|
|
258
|
+
nonInteractive: true,
|
|
259
|
+
supportsDryRun: options.some((option) => option.name === "dry-run"),
|
|
194
260
|
supportsAgentFilters: FILTERABLE_COMMANDS.has(command),
|
|
195
261
|
agentFilterOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_FILTER_OPTIONS : [],
|
|
196
262
|
agentOutputOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_OUTPUT_OPTIONS : [],
|
|
@@ -207,18 +273,15 @@ function printCommandHelp(command, flags = {}) {
|
|
|
207
273
|
console.log("Usage:");
|
|
208
274
|
for (const line of def.usage) console.log(` ${line}`);
|
|
209
275
|
console.log("");
|
|
210
|
-
console.log("
|
|
211
|
-
|
|
212
|
-
|
|
276
|
+
console.log("Options:");
|
|
277
|
+
for (const option of getCommandHelpOptions(command)) {
|
|
278
|
+
const suffix = option.required ? " [required]" : "";
|
|
279
|
+
console.log(` ${option.label.padEnd(34)} ${option.description}${suffix}`);
|
|
280
|
+
}
|
|
281
|
+
if (Array.isArray(def.examples) && def.examples.length > 0) {
|
|
213
282
|
console.log("");
|
|
214
|
-
console.log("
|
|
215
|
-
for (const
|
|
216
|
-
console.log(` ${option.flag.padEnd(14)} ${option.description}`);
|
|
217
|
-
}
|
|
218
|
-
console.log("Agent output options:");
|
|
219
|
-
for (const option of AGENT_OUTPUT_OPTIONS) {
|
|
220
|
-
console.log(` ${option.flag.padEnd(14)} ${option.description}`);
|
|
221
|
-
}
|
|
283
|
+
console.log("Examples:");
|
|
284
|
+
for (const line of def.examples) console.log(` ${line}`);
|
|
222
285
|
}
|
|
223
286
|
return 0;
|
|
224
287
|
}
|
|
@@ -625,6 +688,13 @@ async function main() {
|
|
|
625
688
|
summarizeActivityTime,
|
|
626
689
|
withClient,
|
|
627
690
|
readPasswordFromStdin,
|
|
691
|
+
requireFlag: (commandName, incomingFlags, flagName) => {
|
|
692
|
+
const value = incomingFlags[flagName];
|
|
693
|
+
if (value === undefined || value === null || value === "") {
|
|
694
|
+
throw new Error(formatMissingRequiredFlagMessage(commandName, flagName));
|
|
695
|
+
}
|
|
696
|
+
return value;
|
|
697
|
+
},
|
|
628
698
|
normalizeFtpHistory,
|
|
629
699
|
getLastItem,
|
|
630
700
|
normalizeFitnessThresholds,
|
|
@@ -650,6 +720,9 @@ async function main() {
|
|
|
650
720
|
case "timeline":
|
|
651
721
|
await commandTimeline(flags, commandDeps);
|
|
652
722
|
return;
|
|
723
|
+
case "train-now":
|
|
724
|
+
await commandTrainNow(flags, commandDeps);
|
|
725
|
+
return;
|
|
653
726
|
case "events":
|
|
654
727
|
await commandEvents(flags, commandDeps);
|
|
655
728
|
return;
|
|
@@ -686,6 +759,33 @@ async function main() {
|
|
|
686
759
|
case "power-records":
|
|
687
760
|
await commandPowerRecords(flags, commandDeps);
|
|
688
761
|
return;
|
|
762
|
+
case "workout-library":
|
|
763
|
+
await commandWorkoutLibrary(flags, commandDeps);
|
|
764
|
+
return;
|
|
765
|
+
case "workout-recommend":
|
|
766
|
+
await commandWorkoutRecommend(flags, commandDeps);
|
|
767
|
+
return;
|
|
768
|
+
case "workout-details":
|
|
769
|
+
await commandWorkoutDetails(flags, commandDeps);
|
|
770
|
+
return;
|
|
771
|
+
case "add-workout":
|
|
772
|
+
await commandAddWorkout(flags, commandDeps);
|
|
773
|
+
return;
|
|
774
|
+
case "copy-workout":
|
|
775
|
+
await commandCopyWorkout(flags, commandDeps);
|
|
776
|
+
return;
|
|
777
|
+
case "workout-alternates":
|
|
778
|
+
await commandWorkoutAlternates(flags, commandDeps);
|
|
779
|
+
return;
|
|
780
|
+
case "move-workout":
|
|
781
|
+
await commandMoveWorkout(flags, commandDeps);
|
|
782
|
+
return;
|
|
783
|
+
case "replace-workout":
|
|
784
|
+
await commandReplaceWorkout(flags, commandDeps);
|
|
785
|
+
return;
|
|
786
|
+
case "switch-workout":
|
|
787
|
+
await commandSwitchWorkout(flags, commandDeps);
|
|
788
|
+
return;
|
|
689
789
|
case "logout":
|
|
690
790
|
await commandLogout(flags, commandDeps);
|
|
691
791
|
return;
|
package/src/commands/auth.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
export async function commandLogin(flags, deps) {
|
|
2
2
|
const { withClient, readPasswordFromStdin, writeOutput } = deps;
|
|
3
3
|
const client = await withClient(flags);
|
|
4
|
+
const username = flags.username ?? process.env.TR_USERNAME ?? null;
|
|
4
5
|
const password =
|
|
5
6
|
flags["password-stdin"]
|
|
6
7
|
? await readPasswordFromStdin()
|
|
7
8
|
: flags.password ?? process.env.TR_PASSWORD ?? null;
|
|
8
9
|
const result = await client.login({
|
|
9
|
-
username
|
|
10
|
+
username,
|
|
10
11
|
password,
|
|
11
|
-
returnPath: flags["return-path"] ?? "/app/career
|
|
12
|
+
returnPath: flags["return-path"] ?? (username ? `/app/career/${username}` : "/app/career"),
|
|
12
13
|
});
|
|
13
14
|
await writeOutput(result, { ...flags, json: true });
|
|
14
15
|
}
|
|
@@ -12,6 +12,7 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
12
12
|
name,
|
|
13
13
|
summary: def.summary,
|
|
14
14
|
usage: level >= 2 ? def.usage : undefined,
|
|
15
|
+
examples: level >= 2 ? def.examples : undefined,
|
|
15
16
|
supportsAgentFilters: FILTERABLE_COMMANDS.has(name),
|
|
16
17
|
agentFilters: level >= 3 && FILTERABLE_COMMANDS.has(name) ? AGENT_FILTER_OPTIONS : undefined,
|
|
17
18
|
agentOutputOptions:
|
|
@@ -27,11 +28,11 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
27
28
|
level3: "Apply --from/--to/--type/--contains/--min-tss/--max-tss/--sort/--result-limit/--fields.",
|
|
28
29
|
},
|
|
29
30
|
firstSteps: [
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
31
|
+
"trainerroad-cli capabilities --json",
|
|
32
|
+
"trainerroad-cli whoami --json",
|
|
33
|
+
"trainerroad-cli future --days 30 --json",
|
|
34
|
+
"trainerroad-cli today --tz America/New_York --json",
|
|
35
|
+
"trainerroad-cli help future --json",
|
|
35
36
|
],
|
|
36
37
|
commandCount: commandEntries.length,
|
|
37
38
|
commands: commandEntries,
|
|
@@ -42,17 +43,17 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
42
43
|
{
|
|
43
44
|
pattern: "Summarize future workouts in a date window",
|
|
44
45
|
command:
|
|
45
|
-
"
|
|
46
|
+
"trainerroad-cli future --from 2026-03-01 --to 2026-03-31 --fields id,title,tss,date --sort date --json",
|
|
46
47
|
},
|
|
47
48
|
{
|
|
48
49
|
pattern: "Find hard completed rides",
|
|
49
50
|
command:
|
|
50
|
-
"
|
|
51
|
+
"trainerroad-cli past --days 90 --details --min-tss 80 --sort tss-desc --result-limit 20 --jsonl",
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
54
|
pattern: "Extract only fields for downstream tools",
|
|
54
55
|
command:
|
|
55
|
-
"
|
|
56
|
+
"trainerroad-cli today --details --fields recordType,name,started,tss --json",
|
|
56
57
|
},
|
|
57
58
|
];
|
|
58
59
|
}
|
|
@@ -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
|
+
}
|