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 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
- 3. Discover all commands
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.1.1",
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(" node src/cli.mjs discover --level 1");
60
- console.log(" node src/cli.mjs discover --level 2");
61
- console.log(" node src/cli.mjs discover --command future --level 3 --json");
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(" node src/cli.mjs login --username quinnsprouse --password-stdin");
65
- console.log(" node src/cli.mjs future --days 30 --details --json");
66
- console.log(" node src/cli.mjs today --tz America/New_York --json");
67
- console.log(" node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
68
- console.log(" node src/cli.mjs timeline --target quinnsprouse --public --json");
69
- console.log(" node src/cli.mjs ftp --target quinnsprouse --public --json");
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
- timezoneOption: "--tz <IANA timezone> (defaults to TR_TIMEZONE or system timezone)",
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("Timezone:");
211
- console.log(" --tz <IANA timezone> Override local-day bucketing (defaults: TR_TIMEZONE or system timezone).");
212
- if (FILTERABLE_COMMANDS.has(command)) {
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("Agent filters:");
215
- for (const option of AGENT_FILTER_OPTIONS) {
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;
@@ -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: flags.username ?? process.env.TR_USERNAME ?? null,
10
+ username,
10
11
  password,
11
- returnPath: flags["return-path"] ?? "/app/career/quinnsprouse",
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
- "node src/cli.mjs capabilities --json",
31
- "node src/cli.mjs whoami --json",
32
- "node src/cli.mjs future --days 30 --json",
33
- "node src/cli.mjs today --tz America/New_York --json",
34
- "node src/cli.mjs help future --json",
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
- "node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --fields id,title,tss,date --sort date --json",
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
- "node src/cli.mjs past --days 90 --details --min-tss 80 --sort tss-desc --result-limit 20 --jsonl",
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
- "node src/cli.mjs today --details --fields recordType,name,started,tss --json",
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(/&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
+ }