trainerroad-cli 0.2.0 → 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
@@ -27,6 +27,14 @@ It can also perform a small set of verified calendar writes for planned workouts
27
27
  - replace a workout with a specific alternate
28
28
  - switch a workout between inside and outside
29
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
+
30
38
  ## Install
31
39
 
32
40
  ### Run without install (npx)
@@ -82,6 +90,7 @@ trainerroad-cli workout-library --zone "Endurance" --profile "Sustained Power" -
82
90
  trainerroad-cli workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json
83
91
  trainerroad-cli workout-details --id 18128 --include-chart --json
84
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
85
94
  trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
86
95
  ```
87
96
 
@@ -97,6 +106,7 @@ Then use that planned activity ID:
97
106
 
98
107
  ```bash
99
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
100
110
  trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --json
101
111
  trainerroad-cli replace-workout --id <planned-activity-id> --alternate-id <workout-id> --json
102
112
  trainerroad-cli switch-workout --id <planned-activity-id> --mode outside --json
@@ -130,6 +140,15 @@ Use `--target <username>` and/or `--public` for public mode queries.
130
140
  - `--records-only`: lighter record payloads
131
141
  - `--tz <IANA timezone>`: localize day boundaries/timestamps (defaults to `TR_TIMEZONE` or system timezone)
132
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
+
133
152
  ## Security
134
153
 
135
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.2.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",
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,
@@ -66,25 +68,27 @@ function printGlobalHelp() {
66
68
  for (const note of GLOBAL_NOTES) console.log(` - ${note}`);
67
69
  console.log("");
68
70
  console.log("Progressive disclosure:");
69
- console.log(" node src/cli.mjs discover --level 1");
70
- console.log(" node src/cli.mjs discover --level 2");
71
- 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.');
72
76
  console.log("");
73
77
  console.log("Examples:");
74
- console.log(" node src/cli.mjs login --username quinnsprouse --password-stdin");
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");
77
- console.log(" node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
78
- console.log(" node src/cli.mjs timeline --target quinnsprouse --public --json");
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");
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");
88
92
  }
89
93
 
90
94
  function levenshteinDistance(left, right) {
@@ -197,6 +201,45 @@ function validateCommandFlags(command, flags) {
197
201
  return { unknownFlags, allowlist: Array.from(allowlist) };
198
202
  }
199
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
+
200
243
  function printCommandHelp(command, flags = {}) {
201
244
  const def = COMMANDS[command];
202
245
  if (!def) {
@@ -204,11 +247,16 @@ function printCommandHelp(command, flags = {}) {
204
247
  return 1;
205
248
  }
206
249
  if (flags.json) {
250
+ const options = getCommandHelpOptions(command);
207
251
  const payload = {
208
252
  command,
209
253
  summary: def.summary,
210
254
  usage: def.usage,
211
- 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"),
212
260
  supportsAgentFilters: FILTERABLE_COMMANDS.has(command),
213
261
  agentFilterOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_FILTER_OPTIONS : [],
214
262
  agentOutputOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_OUTPUT_OPTIONS : [],
@@ -225,18 +273,15 @@ function printCommandHelp(command, flags = {}) {
225
273
  console.log("Usage:");
226
274
  for (const line of def.usage) console.log(` ${line}`);
227
275
  console.log("");
228
- console.log("Timezone:");
229
- console.log(" --tz <IANA timezone> Override local-day bucketing (defaults: TR_TIMEZONE or system timezone).");
230
- 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) {
231
282
  console.log("");
232
- console.log("Agent filters:");
233
- for (const option of AGENT_FILTER_OPTIONS) {
234
- console.log(` ${option.flag.padEnd(14)} ${option.description}`);
235
- }
236
- console.log("Agent output options:");
237
- for (const option of AGENT_OUTPUT_OPTIONS) {
238
- console.log(` ${option.flag.padEnd(14)} ${option.description}`);
239
- }
283
+ console.log("Examples:");
284
+ for (const line of def.examples) console.log(` ${line}`);
240
285
  }
241
286
  return 0;
242
287
  }
@@ -643,6 +688,13 @@ async function main() {
643
688
  summarizeActivityTime,
644
689
  withClient,
645
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
+ },
646
698
  normalizeFtpHistory,
647
699
  getLastItem,
648
700
  normalizeFitnessThresholds,
@@ -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
  }
@@ -1,14 +1,6 @@
1
1
  const ALTERNATE_CATEGORIES = new Set(["similar", "easier", "harder", "longer", "shorter"]);
2
2
  const SWITCH_MODES = new Set(["inside", "outside"]);
3
3
 
4
- function requireFlag(flags, name) {
5
- const value = flags[name];
6
- if (value === undefined || value === null || value === "") {
7
- throw new Error(`Missing required flag --${name}.`);
8
- }
9
- return value;
10
- }
11
-
12
4
  function toIsoDateFromApiDate(date) {
13
5
  if (!date || typeof date !== "object") return null;
14
6
  const year = String(date.year ?? "").padStart(4, "0");
@@ -69,18 +61,10 @@ async function requirePrivateMember(flags, deps) {
69
61
  return { client, memberInfo };
70
62
  }
71
63
 
72
- async function fetchBeforeAfter(flags, deps, mutate) {
73
- const { client, memberInfo } = await requirePrivateMember(flags, deps);
74
- const plannedActivityId = String(requireFlag(flags, "id"));
75
- const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
76
- const mutation = await mutate({ client, memberInfo, plannedActivityId, before });
77
- const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
78
- return { client, memberInfo, plannedActivityId, before, after, mutation };
79
- }
80
-
81
64
  export async function commandWorkoutAlternates(flags, deps) {
82
- const { isJsonMode, writeOutput } = deps;
65
+ const { isJsonMode, requireFlag, writeOutput } = deps;
83
66
  const category = String(flags.category ?? "similar").toLowerCase();
67
+ const plannedActivityId = String(requireFlag("workout-alternates", flags, "id"));
84
68
  if (!ALTERNATE_CATEGORIES.has(category)) {
85
69
  throw new Error(
86
70
  `Invalid --category "${category}". Expected one of: ${Array.from(ALTERNATE_CATEGORIES).join(", ")}.`,
@@ -88,7 +72,6 @@ export async function commandWorkoutAlternates(flags, deps) {
88
72
  }
89
73
 
90
74
  const { client, memberInfo } = await requirePrivateMember(flags, deps);
91
- const plannedActivityId = String(requireFlag(flags, "id"));
92
75
  const plannedActivity = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
93
76
  const alternates = await client.getPlannedActivityAlternates(
94
77
  plannedActivityId,
@@ -128,14 +111,47 @@ export async function commandWorkoutAlternates(flags, deps) {
128
111
  }
129
112
 
130
113
  export async function commandMoveWorkout(flags, deps) {
131
- const { isJsonMode, writeOutput } = deps;
132
- const newDate = String(requireFlag(flags, "to"));
133
- const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
134
- flags,
135
- deps,
136
- async ({ client, memberInfo, plannedActivityId }) =>
137
- client.movePlannedActivity(plannedActivityId, newDate, memberInfo.username),
138
- );
114
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
115
+ const dryRun = toBoolean(flags["dry-run"], false);
116
+ const plannedActivityId = String(requireFlag("move-workout", flags, "id"));
117
+ const newDate = String(requireFlag("move-workout", flags, "to"));
118
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
119
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
120
+ const beforeSummary = summarizePlannedActivity(before);
121
+ const noop = beforeSummary?.date === newDate;
122
+
123
+ if (dryRun || noop) {
124
+ const payload = {
125
+ generatedAt: new Date().toISOString(),
126
+ command: "move-workout",
127
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
128
+ query: { plannedActivityId, to: newDate },
129
+ dryRun,
130
+ noop,
131
+ before: beforeSummary,
132
+ after: noop ? beforeSummary : { ...beforeSummary, date: newDate },
133
+ mutation: null,
134
+ message: noop
135
+ ? `Workout is already scheduled on ${newDate}.`
136
+ : `Would move ${beforeSummary?.workoutName ?? "workout"} to ${newDate}.`,
137
+ };
138
+
139
+ if (!isJsonMode(flags)) {
140
+ await writeOutput(payload, flags, (value) => {
141
+ if (value.noop) {
142
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} is already on ${value.query.to}`;
143
+ }
144
+ return `Would move ${value.before?.workoutName ?? "workout"} | plannedActivityId=${value.query.plannedActivityId} | ${value.before?.date ?? "?"} -> ${value.query.to}\nNo changes made.`;
145
+ });
146
+ return;
147
+ }
148
+
149
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
150
+ return;
151
+ }
152
+
153
+ const mutation = await client.movePlannedActivity(plannedActivityId, newDate, memberInfo.username);
154
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
139
155
 
140
156
  const payload = {
141
157
  generatedAt: new Date().toISOString(),
@@ -158,22 +174,54 @@ export async function commandMoveWorkout(flags, deps) {
158
174
  }
159
175
 
160
176
  export async function commandReplaceWorkout(flags, deps) {
161
- const { isJsonMode, toBoolean, writeOutput } = deps;
162
- const alternateWorkoutId = Number(requireFlag(flags, "alternate-id"));
177
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
178
+ const dryRun = toBoolean(flags["dry-run"], false);
179
+ const plannedActivityId = String(requireFlag("replace-workout", flags, "id"));
180
+ const alternateWorkoutId = Number(requireFlag("replace-workout", flags, "alternate-id"));
163
181
  if (!Number.isFinite(alternateWorkoutId)) {
164
182
  throw new Error(`Invalid --alternate-id "${flags["alternate-id"]}". Expected a numeric workout ID.`);
165
183
  }
166
184
  const updateDuration = toBoolean(flags["update-duration"], false);
185
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
186
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
187
+ const beforeSummary = summarizePlannedActivity(before);
188
+ const noop = Number(beforeSummary?.workoutId) === alternateWorkoutId;
167
189
 
168
- const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
169
- flags,
170
- deps,
171
- async ({ client, memberInfo, plannedActivityId }) =>
172
- client.replacePlannedActivityWithAlternate(plannedActivityId, alternateWorkoutId, {
173
- updateDuration,
174
- usernameForReferer: memberInfo.username,
175
- }),
176
- );
190
+ if (dryRun || noop) {
191
+ const payload = {
192
+ generatedAt: new Date().toISOString(),
193
+ command: "replace-workout",
194
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
195
+ query: { plannedActivityId, alternateWorkoutId, updateDuration },
196
+ dryRun,
197
+ noop,
198
+ before: beforeSummary,
199
+ after: noop ? beforeSummary : null,
200
+ mutation: null,
201
+ message: noop
202
+ ? `Workout already uses alternate workout ${alternateWorkoutId}.`
203
+ : `Would replace workout ${beforeSummary?.workoutId ?? "?"} with ${alternateWorkoutId}.`,
204
+ };
205
+
206
+ if (!isJsonMode(flags)) {
207
+ await writeOutput(payload, flags, (value) => {
208
+ if (value.noop) {
209
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} already uses workoutId=${value.query.alternateWorkoutId}`;
210
+ }
211
+ return `Would replace workout | plannedActivityId=${value.query.plannedActivityId} | workoutId=${value.before?.workoutId ?? "?"} -> alternateWorkoutId=${value.query.alternateWorkoutId}\nNo changes made.`;
212
+ });
213
+ return;
214
+ }
215
+
216
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
217
+ return;
218
+ }
219
+
220
+ const mutation = await client.replacePlannedActivityWithAlternate(plannedActivityId, alternateWorkoutId, {
221
+ updateDuration,
222
+ usernameForReferer: memberInfo.username,
223
+ });
224
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
177
225
 
178
226
  const payload = {
179
227
  generatedAt: new Date().toISOString(),
@@ -196,18 +244,50 @@ export async function commandReplaceWorkout(flags, deps) {
196
244
  }
197
245
 
198
246
  export async function commandSwitchWorkout(flags, deps) {
199
- const { isJsonMode, writeOutput } = deps;
200
- const mode = String(requireFlag(flags, "mode")).toLowerCase();
247
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
248
+ const dryRun = toBoolean(flags["dry-run"], false);
249
+ const plannedActivityId = String(requireFlag("switch-workout", flags, "id"));
250
+ const mode = String(requireFlag("switch-workout", flags, "mode")).toLowerCase();
201
251
  if (!SWITCH_MODES.has(mode)) {
202
252
  throw new Error(`Invalid --mode "${mode}". Expected one of: ${Array.from(SWITCH_MODES).join(", ")}.`);
203
253
  }
254
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
255
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
256
+ const beforeSummary = summarizePlannedActivity(before);
257
+ const noop = beforeSummary?.isOutside === (mode === "outside");
204
258
 
205
- const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
206
- flags,
207
- deps,
208
- async ({ client, memberInfo, plannedActivityId }) =>
209
- client.switchPlannedActivityMode(plannedActivityId, mode, memberInfo.username),
210
- );
259
+ if (dryRun || noop) {
260
+ const payload = {
261
+ generatedAt: new Date().toISOString(),
262
+ command: "switch-workout",
263
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
264
+ query: { plannedActivityId, mode },
265
+ dryRun,
266
+ noop,
267
+ before: beforeSummary,
268
+ after: noop ? beforeSummary : { ...beforeSummary, isOutside: mode === "outside" },
269
+ mutation: null,
270
+ message: noop
271
+ ? `Workout is already in ${mode} mode.`
272
+ : `Would switch workout to ${mode} mode.`,
273
+ };
274
+
275
+ if (!isJsonMode(flags)) {
276
+ await writeOutput(payload, flags, (value) => {
277
+ if (value.noop) {
278
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} is already ${value.query.mode}`;
279
+ }
280
+ return `Would switch workout | plannedActivityId=${value.query.plannedActivityId} | outside=${value.before?.isOutside} -> ${value.query.mode === "outside"}\nNo changes made.`;
281
+ });
282
+ return;
283
+ }
284
+
285
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
286
+ return;
287
+ }
288
+
289
+ const mutation = await client.switchPlannedActivityMode(plannedActivityId, mode, memberInfo.username);
290
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
211
291
 
212
292
  const payload = {
213
293
  generatedAt: new Date().toISOString(),
@@ -100,14 +100,6 @@ function summarizeChart(chartData, pointLimit = 200) {
100
100
  };
101
101
  }
102
102
 
103
- function requireFlag(flags, name) {
104
- const value = flags[name];
105
- if (value === undefined || value === null || value === "") {
106
- throw new Error(`Missing required flag --${name}.`);
107
- }
108
- return value;
109
- }
110
-
111
103
  async function requirePrivateMember(flags, deps) {
112
104
  const { withClient } = deps;
113
105
  const client = await withClient(flags);
@@ -125,30 +117,6 @@ async function sleep(ms) {
125
117
  await new Promise((resolve) => setTimeout(resolve, ms));
126
118
  }
127
119
 
128
- async function findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId) {
129
- const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
130
- const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
131
- .filter((item) => {
132
- const date = item?.date;
133
- const asIso = date
134
- ? `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`
135
- : null;
136
- return asIso === dateIso;
137
- })
138
- .map((item) => item.id)
139
- .filter(Boolean);
140
-
141
- if (candidateIds.length === 0) return null;
142
- const details = await client.getPlannedActivitiesByIds(
143
- memberInfo.memberId,
144
- memberInfo.username,
145
- candidateIds,
146
- );
147
- const matches = details.filter((item) => Number(item?.workout?.id) === Number(workoutId));
148
- if (matches.length === 0) return null;
149
- return matches.sort((a, b) => String(b.modified ?? "").localeCompare(String(a.modified ?? "")))[0];
150
- }
151
-
152
120
  async function listPlannedWorkoutsOnDate(client, memberInfo, dateIso) {
153
121
  const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
154
122
  const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
@@ -167,8 +135,8 @@ async function listPlannedWorkoutsOnDate(client, memberInfo, dateIso) {
167
135
  }
168
136
 
169
137
  export async function commandWorkoutDetails(flags, deps) {
170
- const { isJsonMode, requirePositiveInteger, toBoolean, writeOutput } = deps;
171
- const workoutId = Number(requireFlag(flags, "id"));
138
+ const { isJsonMode, requireFlag, requirePositiveInteger, toBoolean, writeOutput } = deps;
139
+ const workoutId = Number(requireFlag("workout-details", flags, "id"));
172
140
  if (!Number.isFinite(workoutId)) {
173
141
  throw new Error(`Invalid --id "${flags.id}". Expected a numeric workout ID.`);
174
142
  }
@@ -217,9 +185,10 @@ export async function commandWorkoutDetails(flags, deps) {
217
185
  }
218
186
 
219
187
  export async function commandAddWorkout(flags, deps) {
220
- const { isJsonMode, toBoolean, writeOutput } = deps;
221
- const workoutId = Number(requireFlag(flags, "workout-id"));
222
- const dateIso = String(requireFlag(flags, "date"));
188
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
189
+ const dryRun = toBoolean(flags["dry-run"], false);
190
+ const workoutId = Number(requireFlag("add-workout", flags, "workout-id"));
191
+ const dateIso = String(requireFlag("add-workout", flags, "date"));
223
192
  if (!Number.isFinite(workoutId)) {
224
193
  throw new Error(`Invalid --workout-id "${flags["workout-id"]}". Expected a numeric workout ID.`);
225
194
  }
@@ -232,6 +201,54 @@ export async function commandAddWorkout(flags, deps) {
232
201
  throw new Error(`Workout ${workoutId} was not found in the library.`);
233
202
  }
234
203
 
204
+ const existingOnDate = await listPlannedWorkoutsOnDate(client, memberInfo, dateIso);
205
+ const beforeIds = new Set(existingOnDate.map((item) => item.id));
206
+ const matchingExisting = existingOnDate
207
+ .filter((item) => {
208
+ const plannedWorkoutId = Number(item?.workout?.id);
209
+ const plannedOutside = item?.workout?.isOutside;
210
+ return plannedWorkoutId === workoutId && (plannedOutside == null || plannedOutside === outside);
211
+ })
212
+ .map((item) => summarizePlannedActivity(item))
213
+ .filter(Boolean);
214
+
215
+ if (dryRun) {
216
+ const payload = {
217
+ generatedAt: new Date().toISOString(),
218
+ command: "add-workout",
219
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
220
+ query: { workoutId, date: dateIso, outside },
221
+ dryRun,
222
+ noop: false,
223
+ workout,
224
+ existingMatches: matchingExisting,
225
+ created: null,
226
+ attempts: [],
227
+ warnings:
228
+ matchingExisting.length > 0
229
+ ? [`Found ${matchingExisting.length} matching workout(s) already scheduled on ${dateIso}.`]
230
+ : [],
231
+ message: `Would add workout ${workoutId} to ${dateIso}.`,
232
+ };
233
+
234
+ if (!isJsonMode(flags)) {
235
+ await writeOutput(payload, flags, (value) => {
236
+ const lines = [
237
+ `Would add ${value.workout?.workoutName ?? "workout"} to ${value.query.date} | workoutId=${value.query.workoutId}`,
238
+ ];
239
+ if (value.warnings.length > 0) {
240
+ lines.push(...value.warnings.map((warning) => `Warning: ${warning}`));
241
+ }
242
+ lines.push("No changes made.");
243
+ return lines.join("\n");
244
+ });
245
+ return;
246
+ }
247
+
248
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
249
+ return;
250
+ }
251
+
235
252
  const attempts = await client.tryAddWorkoutToCalendar(workoutId, dateIso, {
236
253
  outside,
237
254
  usernameForReferer: memberInfo.username,
@@ -239,7 +256,10 @@ export async function commandAddWorkout(flags, deps) {
239
256
 
240
257
  let created = null;
241
258
  for (let attempt = 0; attempt < 4; attempt += 1) {
242
- created = await findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId);
259
+ const afterTarget = await listPlannedWorkoutsOnDate(client, memberInfo, dateIso);
260
+ created = afterTarget.find(
261
+ (item) => !beforeIds.has(item.id) && Number(item?.workout?.id) === Number(workoutId),
262
+ );
243
263
  if (created) break;
244
264
  await sleep(750);
245
265
  }
@@ -277,13 +297,55 @@ export async function commandAddWorkout(flags, deps) {
277
297
  }
278
298
 
279
299
  export async function commandCopyWorkout(flags, deps) {
280
- const { isJsonMode, writeOutput } = deps;
281
- const sourcePlannedActivityId = String(requireFlag(flags, "id"));
282
- const targetDate = String(requireFlag(flags, "date"));
300
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
301
+ const dryRun = toBoolean(flags["dry-run"], false);
302
+ const sourcePlannedActivityId = String(requireFlag("copy-workout", flags, "id"));
303
+ const targetDate = String(requireFlag("copy-workout", flags, "date"));
283
304
  const { client, memberInfo } = await requirePrivateMember(flags, deps);
284
305
  const source = await client.getPlannedActivity(sourcePlannedActivityId, memberInfo.username);
285
306
  const beforeTarget = await listPlannedWorkoutsOnDate(client, memberInfo, targetDate);
286
307
  const beforeIds = new Set(beforeTarget.map((item) => item.id));
308
+ const matchingExisting = beforeTarget
309
+ .filter((item) => Number(item?.workout?.id) === Number(source?.workout?.id))
310
+ .map((item) => summarizePlannedActivity(item))
311
+ .filter(Boolean);
312
+
313
+ if (dryRun) {
314
+ const payload = {
315
+ generatedAt: new Date().toISOString(),
316
+ command: "copy-workout",
317
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
318
+ query: { sourcePlannedActivityId, date: targetDate },
319
+ dryRun,
320
+ noop: false,
321
+ source: summarizePlannedActivity(source),
322
+ existingMatches: matchingExisting,
323
+ created: null,
324
+ mutation: null,
325
+ warnings:
326
+ matchingExisting.length > 0
327
+ ? [`Found ${matchingExisting.length} matching workout(s) already scheduled on ${targetDate}.`]
328
+ : [],
329
+ message: `Would copy workout to ${targetDate}.`,
330
+ };
331
+
332
+ if (!isJsonMode(flags)) {
333
+ await writeOutput(payload, flags, (value) => {
334
+ const lines = [
335
+ `Would copy ${value.source?.workoutName ?? "workout"} to ${value.query.date} | sourcePlannedActivityId=${value.query.sourcePlannedActivityId}`,
336
+ ];
337
+ if (value.warnings.length > 0) {
338
+ lines.push(...value.warnings.map((warning) => `Warning: ${warning}`));
339
+ }
340
+ lines.push("No changes made.");
341
+ return lines.join("\n");
342
+ });
343
+ return;
344
+ }
345
+
346
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
347
+ return;
348
+ }
287
349
 
288
350
  const mutation = await client.copyPlannedActivity(sourcePlannedActivityId, targetDate, memberInfo.username);
289
351
 
@@ -2,172 +2,272 @@ export const COMMANDS = {
2
2
  help: {
3
3
  summary: "Show global help or command help.",
4
4
  usage: [
5
- "node src/cli.mjs help",
6
- "node src/cli.mjs help <command>",
7
- "node src/cli.mjs <command> --help",
5
+ "trainerroad-cli help",
6
+ "trainerroad-cli help <command>",
7
+ "trainerroad-cli <command> --help",
8
+ ],
9
+ examples: [
10
+ "trainerroad-cli help future",
11
+ "trainerroad-cli help future --json",
8
12
  ],
9
13
  },
10
14
  discover: {
11
15
  summary: "Agent-oriented command discovery with progressive disclosure levels.",
12
16
  usage: [
13
- "node src/cli.mjs discover",
14
- "node src/cli.mjs discover --level 1|2|3 [--json]",
15
- "node src/cli.mjs discover --command future --level 3 --json",
17
+ "trainerroad-cli discover",
18
+ "trainerroad-cli discover --level 1|2|3 [--json]",
19
+ "trainerroad-cli discover --command future --level 3 --json",
20
+ ],
21
+ examples: [
22
+ "trainerroad-cli discover --level 1",
23
+ "trainerroad-cli discover --command workout-library --level 3 --json",
16
24
  ],
17
25
  },
18
26
  capabilities: {
19
27
  summary: "Show supported auth/data capabilities (private + public modes).",
20
- usage: ["node src/cli.mjs capabilities [--json]"],
28
+ usage: ["trainerroad-cli capabilities [--json]"],
29
+ examples: ["trainerroad-cli capabilities --json"],
21
30
  },
22
31
  login: {
23
32
  summary: "Authenticate and persist cookie session for private data access.",
24
33
  usage: [
25
- "node src/cli.mjs login --username <u> --password <p> [--return-path /app/career/<username>]",
26
- "node src/cli.mjs login --username <u> --password-stdin",
34
+ "trainerroad-cli login --username <username> --password <password> [--return-path /app/career/<username>]",
35
+ "trainerroad-cli login --username <username> --password-stdin",
36
+ ],
37
+ examples: [
38
+ "trainerroad-cli login --username quinnsprouse --password-stdin",
39
+ "TR_PASSWORD='<password>' trainerroad-cli login --username quinnsprouse",
27
40
  ],
28
41
  },
29
42
  whoami: {
30
43
  summary: "Fetch authenticated member profile info (`/app/api/member-info`).",
31
- usage: ["node src/cli.mjs whoami [--json]"],
44
+ usage: ["trainerroad-cli whoami [--json]"],
45
+ examples: ["trainerroad-cli whoami --json"],
32
46
  },
33
47
  timeline: {
34
48
  summary: "Get profile summary (private full timeline or public TSS-derived summary).",
35
49
  usage: [
36
- "node src/cli.mjs timeline [--target <username>] [--public] [--full] [--json]",
37
- "node src/cli.mjs timeline --target quinnsprouse --public --json",
50
+ "trainerroad-cli timeline [--target <username>] [--public] [--full] [--json]",
51
+ ],
52
+ examples: [
53
+ "trainerroad-cli timeline --json",
54
+ "trainerroad-cli timeline --target quinnsprouse --public --json",
38
55
  ],
39
56
  },
40
57
  "train-now": {
41
58
  summary: "Fetch TrainerRoad AI suggested workouts (TrainNow) for a target duration (private mode).",
42
59
  usage: [
43
- "node src/cli.mjs train-now [--duration <minutes>] [--num-suggestions <n>] [--category climbing|endurance|attacking] [--json|--jsonl]",
60
+ "trainerroad-cli train-now [--duration <minutes>] [--num-suggestions <count>] [--category climbing|endurance|attacking] [--json|--jsonl]",
61
+ ],
62
+ examples: [
63
+ "trainerroad-cli train-now --duration 60 --json",
64
+ "trainerroad-cli train-now --duration 90 --category endurance --json",
44
65
  ],
45
66
  },
46
67
  events: {
47
68
  summary: "Show calendar events/races from timeline (private mode).",
48
69
  usage: [
49
- "node src/cli.mjs events [--full] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <value>] [--contains <text>] [--min-tss <n>] [--max-tss <n>] [--sort date|date-desc|name|name-desc|tss|tss-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
70
+ "trainerroad-cli events [--full] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <value>] [--contains <text>] [--min-tss <number>] [--max-tss <number>] [--sort date|date-desc|name|name-desc|tss|tss-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
71
+ ],
72
+ examples: [
73
+ "trainerroad-cli events --from 2026-03-01 --to 2026-03-31 --json",
74
+ "trainerroad-cli events --contains gravel --sort date --result-limit 10 --json",
50
75
  ],
51
76
  },
52
77
  annotations: {
53
78
  summary: "Show timeline annotations (time off, notes, illness/injury markers) (private mode).",
54
79
  usage: [
55
- "node src/cli.mjs annotations [--full] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <value>] [--contains <text>] [--sort date|date-desc|name|name-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
80
+ "trainerroad-cli annotations [--full] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <value>] [--contains <text>] [--sort date|date-desc|name|name-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
81
+ ],
82
+ examples: [
83
+ "trainerroad-cli annotations --from 2026-01-01 --json",
84
+ "trainerroad-cli annotations --contains injury --sort date-desc --json",
56
85
  ],
57
86
  },
58
87
  levels: {
59
88
  summary: "Show progression levels by zone (private mode).",
60
89
  usage: [
61
- "node src/cli.mjs levels [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <zone|progressionId>] [--contains <text>] [--sort name|name-desc|date|date-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
90
+ "trainerroad-cli levels [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <zone|progressionId>] [--contains <text>] [--sort name|name-desc|date|date-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
91
+ ],
92
+ examples: [
93
+ "trainerroad-cli levels --json",
94
+ "trainerroad-cli levels --type endurance --json",
62
95
  ],
63
96
  },
64
97
  plan: {
65
98
  summary: "Show training plan data (current plan, phases, or all plans) (private mode).",
66
99
  usage: [
67
- "node src/cli.mjs plan [--view current|phases|plans] [--full] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <value>] [--contains <text>] [--sort date|date-desc|name|name-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
100
+ "trainerroad-cli plan [--view current|phases|plans] [--full] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--type <value>] [--contains <text>] [--sort date|date-desc|name|name-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
101
+ ],
102
+ examples: [
103
+ "trainerroad-cli plan --view current --json",
104
+ "trainerroad-cli plan --view phases --json",
68
105
  ],
69
106
  },
70
107
  "weight-history": {
71
108
  summary: "Show historical body-weight entries (private mode).",
72
109
  usage: [
73
- "node src/cli.mjs weight-history [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--contains <text>] [--sort date|date-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
110
+ "trainerroad-cli weight-history [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--contains <text>] [--sort date|date-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
111
+ ],
112
+ examples: [
113
+ "trainerroad-cli weight-history --json",
114
+ "trainerroad-cli weight-history --from 2026-01-01 --sort date-desc --json",
74
115
  ],
75
116
  },
76
117
  today: {
77
118
  summary: "Show today's planned/completed activity for private or public profile mode.",
78
119
  usage: [
79
- "node src/cli.mjs today [--date YYYY-MM-DD] [--target <username>] [--public] [--details] [--type <value>] [--contains <text>] [--min-tss <n>] [--max-tss <n>] [--sort date|date-desc|tss|tss-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
120
+ "trainerroad-cli today [--date YYYY-MM-DD] [--target <username>] [--public] [--details] [--type <value>] [--contains <text>] [--min-tss <number>] [--max-tss <number>] [--sort date|date-desc|tss|tss-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
121
+ ],
122
+ examples: [
123
+ "trainerroad-cli today --json",
124
+ "trainerroad-cli today --target quinnsprouse --public --tz America/New_York --json",
80
125
  ],
81
126
  },
82
127
  future: {
83
128
  summary: "Show future plan data for private or public profile mode.",
84
129
  usage: [
85
- "node src/cli.mjs future [--days <n>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--target <username>] [--public] [--details] [--type <value>] [--contains <text>] [--min-tss <n>] [--max-tss <n>] [--sort date|date-desc|tss|tss-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
130
+ "trainerroad-cli future [--days <count>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--target <username>] [--public] [--details] [--type <value>] [--contains <text>] [--min-tss <number>] [--max-tss <number>] [--sort date|date-desc|tss|tss-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
131
+ ],
132
+ examples: [
133
+ "trainerroad-cli future --days 30 --json",
134
+ "trainerroad-cli future --from 2026-03-01 --to 2026-03-31 --fields id,title,tss,date --json",
86
135
  ],
87
136
  },
88
137
  past: {
89
138
  summary: "Show past activity data for private or public profile mode.",
90
139
  usage: [
91
- "node src/cli.mjs past [--days <n>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit <n>] [--target <username>] [--public] [--details] [--type <value>] [--contains <text>] [--min-tss <n>] [--max-tss <n>] [--sort date|date-desc|tss|tss-desc] [--result-limit <n>] [--fields a,b] [--records-only] [--json|--jsonl]",
140
+ "trainerroad-cli past [--days <count>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit <count>] [--target <username>] [--public] [--details] [--type <value>] [--contains <text>] [--min-tss <number>] [--max-tss <number>] [--sort date|date-desc|tss|tss-desc] [--result-limit <count>] [--fields a,b] [--records-only] [--json|--jsonl]",
141
+ ],
142
+ examples: [
143
+ "trainerroad-cli past --days 30 --json",
144
+ "trainerroad-cli past --days 90 --min-tss 80 --sort tss-desc --result-limit 20 --jsonl",
92
145
  ],
93
146
  },
94
147
  ftp: {
95
148
  summary: "Show FTP snapshot and FTP history (private or public mode).",
96
149
  usage: [
97
- "node src/cli.mjs ftp [--target <username>] [--public] [--history-limit <n>] [--json|--jsonl]",
150
+ "trainerroad-cli ftp [--target <username>] [--public] [--history-limit <count>] [--json|--jsonl]",
151
+ ],
152
+ examples: [
153
+ "trainerroad-cli ftp --json",
154
+ "trainerroad-cli ftp --target quinnsprouse --public --history-limit 12 --json",
98
155
  ],
99
156
  },
100
157
  "ftp-prediction": {
101
158
  summary: "Show AI FTP detection eligibility/status and progression impact (private mode).",
102
- usage: ["node src/cli.mjs ftp-prediction [--json]"],
159
+ usage: ["trainerroad-cli ftp-prediction [--json]"],
160
+ examples: ["trainerroad-cli ftp-prediction --json"],
103
161
  },
104
162
  "power-ranking": {
105
163
  summary: "Show best-power percentile ranking by duration (private mode).",
106
- usage: ["node src/cli.mjs power-ranking [--json|--jsonl]"],
164
+ usage: ["trainerroad-cli power-ranking [--json|--jsonl]"],
165
+ examples: ["trainerroad-cli power-ranking --json"],
107
166
  },
108
167
  "power-records": {
109
168
  summary: "Show date-range personal power records from TrainerRoad PR endpoint (private mode).",
110
169
  usage: [
111
- "node src/cli.mjs power-records [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--row-type 100|101] [--indoor-only true|false] [--limit <n>] [--full] [--json|--jsonl]",
170
+ "trainerroad-cli power-records [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--row-type 100|101] [--indoor-only true|false] [--limit <count>] [--full] [--json|--jsonl]",
171
+ ],
172
+ examples: [
173
+ "trainerroad-cli power-records --start-date 2026-01-01 --end-date 2026-03-01 --json",
174
+ "trainerroad-cli power-records --row-type 101 --indoor-only true --limit 20 --jsonl",
112
175
  ],
113
176
  },
114
177
  "workout-library": {
115
178
  summary: "Search the TrainerRoad workout library with agent-friendly filters (private mode).",
116
179
  usage: [
117
- 'node src/cli.mjs workout-library [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--limit <n>] [--page-size <n>] [--json|--jsonl]',
180
+ "trainerroad-cli workout-library [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <number>] [--max-tss <number>] [--min-level <number>] [--max-level <number>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--limit <count>] [--page-size <count>] [--json|--jsonl]",
181
+ ],
182
+ examples: [
183
+ "trainerroad-cli workout-library --zone Endurance --profile \"Sustained Power\" --min-duration 45 --max-duration 75 --json",
184
+ "trainerroad-cli workout-library --search Baxter --limit 5 --json",
118
185
  ],
119
186
  },
120
187
  "workout-recommend": {
121
188
  summary: "Recommend library workouts by ranking candidates against target duration/level/TSS (private mode).",
122
189
  usage: [
123
- 'node src/cli.mjs workout-recommend [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--target-duration <minutes>] [--target-tss <n>] [--target-level <n>] [--count <n>] [--candidate-limit <n>] [--page-size <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--json|--jsonl]',
190
+ "trainerroad-cli workout-recommend [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <number>] [--max-tss <number>] [--min-level <number>] [--max-level <number>] [--target-duration <minutes>] [--target-tss <number>] [--target-level <number>] [--count <count>] [--candidate-limit <count>] [--page-size <count>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--json|--jsonl]",
191
+ ],
192
+ examples: [
193
+ "trainerroad-cli workout-recommend --zone Endurance --profile \"Sustained Power\" --target-duration 60 --target-level 1.0 --count 3 --json",
194
+ "trainerroad-cli workout-recommend --search threshold --target-duration 90 --count 5 --json",
124
195
  ],
125
196
  },
126
197
  "workout-details": {
127
198
  summary: "Fetch detailed workout-library metadata for a workout ID (private mode).",
128
199
  usage: [
129
- "node src/cli.mjs workout-details --id <workout-id> [--include-chart true|false] [--chart-point-limit <n>] [--json|--jsonl]",
200
+ "trainerroad-cli workout-details --id <workout-id> [--include-chart true|false] [--chart-point-limit <count>] [--json|--jsonl]",
201
+ ],
202
+ examples: [
203
+ "trainerroad-cli workout-details --id 18128 --json",
204
+ "trainerroad-cli workout-details --id 18128 --include-chart --chart-point-limit 50 --json",
130
205
  ],
131
206
  },
132
207
  "add-workout": {
133
208
  summary: "Add a library workout to the calendar on a target date (private mode; reconciles flaky API responses).",
134
209
  usage: [
135
- "node src/cli.mjs add-workout --workout-id <workout-id> --date YYYY-MM-DD [--outside true|false] [--json|--jsonl]",
210
+ "trainerroad-cli add-workout --workout-id <workout-id> --date YYYY-MM-DD [--outside true|false] [--dry-run] [--json|--jsonl]",
211
+ ],
212
+ examples: [
213
+ "trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --dry-run",
214
+ "trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --json",
136
215
  ],
137
216
  },
138
217
  "copy-workout": {
139
218
  summary: "Copy an existing planned workout to another date (private mode).",
140
219
  usage: [
141
- "node src/cli.mjs copy-workout --id <planned-activity-id> --date YYYY-MM-DD [--json|--jsonl]",
220
+ "trainerroad-cli copy-workout --id <planned-activity-id> --date YYYY-MM-DD [--dry-run] [--json|--jsonl]",
221
+ ],
222
+ examples: [
223
+ "trainerroad-cli copy-workout --id 123456 --date 2026-03-16 --dry-run",
224
+ "trainerroad-cli copy-workout --id 123456 --date 2026-03-16 --json",
142
225
  ],
143
226
  },
144
227
  "workout-alternates": {
145
228
  summary: "List alternate workout options for a planned workout (private mode).",
146
229
  usage: [
147
- "node src/cli.mjs workout-alternates --id <planned-activity-id> [--category similar|easier|harder|longer|shorter] [--json|--jsonl]",
230
+ "trainerroad-cli workout-alternates --id <planned-activity-id> [--category similar|easier|harder|longer|shorter] [--json|--jsonl]",
231
+ ],
232
+ examples: [
233
+ "trainerroad-cli workout-alternates --id 123456 --json",
234
+ "trainerroad-cli workout-alternates --id 123456 --category easier --json",
148
235
  ],
149
236
  },
150
237
  "move-workout": {
151
238
  summary: "Move a planned workout to a different date (private mode).",
152
239
  usage: [
153
- "node src/cli.mjs move-workout --id <planned-activity-id> --to YYYY-MM-DD [--json|--jsonl]",
240
+ "trainerroad-cli move-workout --id <planned-activity-id> --to YYYY-MM-DD [--dry-run] [--json|--jsonl]",
241
+ ],
242
+ examples: [
243
+ "trainerroad-cli move-workout --id 123456 --to 2026-03-13 --dry-run",
244
+ "trainerroad-cli move-workout --id 123456 --to 2026-03-13 --json",
154
245
  ],
155
246
  },
156
247
  "replace-workout": {
157
248
  summary: "Replace a planned workout with a specific alternate workout ID (private mode).",
158
249
  usage: [
159
- "node src/cli.mjs replace-workout --id <planned-activity-id> --alternate-id <workout-id> [--update-duration true|false] [--json|--jsonl]",
250
+ "trainerroad-cli replace-workout --id <planned-activity-id> --alternate-id <workout-id> [--update-duration true|false] [--dry-run] [--json|--jsonl]",
251
+ ],
252
+ examples: [
253
+ "trainerroad-cli replace-workout --id 123456 --alternate-id 18128 --dry-run",
254
+ "trainerroad-cli replace-workout --id 123456 --alternate-id 18128 --json",
160
255
  ],
161
256
  },
162
257
  "switch-workout": {
163
258
  summary: "Switch a planned workout between inside and outside variants (private mode).",
164
259
  usage: [
165
- "node src/cli.mjs switch-workout --id <planned-activity-id> --mode inside|outside [--json|--jsonl]",
260
+ "trainerroad-cli switch-workout --id <planned-activity-id> --mode inside|outside [--dry-run] [--json|--jsonl]",
261
+ ],
262
+ examples: [
263
+ "trainerroad-cli switch-workout --id 123456 --mode outside --dry-run",
264
+ "trainerroad-cli switch-workout --id 123456 --mode outside --json",
166
265
  ],
167
266
  },
168
267
  logout: {
169
268
  summary: "Clear local persisted session.",
170
- usage: ["node src/cli.mjs logout"],
269
+ usage: ["trainerroad-cli logout"],
270
+ examples: ["trainerroad-cli logout"],
171
271
  },
172
272
  };
173
273
 
@@ -176,6 +276,7 @@ export const PROJECT_NOTICE = "Unofficial tool. Not affiliated with or endorsed
176
276
  export const GLOBAL_NOTES = [
177
277
  PROJECT_NOTICE,
178
278
  "Environment: TR_USERNAME, TR_PASSWORD, TR_SESSION_FILE, TR_TIMEZONE",
279
+ "Non-interactive by default: pass flags or stdin; commands do not prompt.",
179
280
  "Timezone: --tz <IANA timezone> (for example America/New_York).",
180
281
  "Output modes: default JSON, --json, --jsonl, --output <path>",
181
282
  "Session file default: .trainerroad/session.json",
@@ -183,6 +284,7 @@ export const GLOBAL_NOTES = [
183
284
  "Public mode: username-based endpoint (`/app/api/tss/{username}`) with limited detail.",
184
285
  "Agent filters: --from --to --type --contains --min-tss --max-tss --sort --result-limit --fields",
185
286
  "Agent output: --records-only",
287
+ "Write commands: use --dry-run to preview calendar mutations before applying them.",
186
288
  ];
187
289
 
188
290
  export const AGENT_FILTER_OPTIONS = [
@@ -216,6 +318,160 @@ export const FILTERABLE_COMMANDS = new Set([
216
318
  "weight-history",
217
319
  ]);
218
320
 
321
+ export const COMMAND_REQUIRED_FLAGS = {
322
+ "workout-details": ["id"],
323
+ "add-workout": ["workout-id", "date"],
324
+ "copy-workout": ["id", "date"],
325
+ "workout-alternates": ["id"],
326
+ "move-workout": ["id", "to"],
327
+ "replace-workout": ["id", "alternate-id"],
328
+ "switch-workout": ["id", "mode"],
329
+ };
330
+
331
+ export const FLAG_DETAILS = {
332
+ help: { description: "Show command help and exit." },
333
+ output: { placeholder: "<path>", description: "Write command output to a file instead of stdout." },
334
+ json: { description: "Emit machine-readable JSON output." },
335
+ jsonl: { description: "Emit newline-delimited JSON records." },
336
+ "session-file": {
337
+ placeholder: "<path>",
338
+ description: "Override the persisted session file path.",
339
+ },
340
+ username: { placeholder: "<username>", description: "TrainerRoad account username." },
341
+ password: {
342
+ placeholder: "<password>",
343
+ description: "TrainerRoad account password. Prefer --password-stdin or TR_PASSWORD.",
344
+ },
345
+ "password-stdin": {
346
+ description: "Read the password from stdin so the command stays non-interactive and shell-history safe.",
347
+ },
348
+ "return-path": {
349
+ placeholder: "<path>",
350
+ description: "Override the login ReturnUrl used during the auth flow.",
351
+ },
352
+ level: { placeholder: "1|2|3", description: "Discovery detail level." },
353
+ command: { placeholder: "<command>", description: "Limit discovery/help output to one command." },
354
+ target: { placeholder: "<username>", description: "Public TrainerRoad profile username to query." },
355
+ public: {
356
+ description: "Force public-mode requests even when an authenticated session exists.",
357
+ },
358
+ full: { description: "Return fuller upstream payload data when the command supports it." },
359
+ duration: { placeholder: "<minutes>", description: "Requested workout duration in minutes." },
360
+ "num-suggestions": {
361
+ placeholder: "<count>",
362
+ description: "Maximum TrainNow suggestions to request.",
363
+ },
364
+ category: {
365
+ placeholder: "<value>",
366
+ description: "Command-specific category filter (for example easier, endurance, or outside).",
367
+ },
368
+ from: { placeholder: "YYYY-MM-DD", description: "Inclusive lower date bound." },
369
+ to: { placeholder: "YYYY-MM-DD", description: "Inclusive upper date bound or move target date." },
370
+ type: { placeholder: "<value>", description: "Command-specific record type filter." },
371
+ contains: { placeholder: "<text>", description: "Case-insensitive substring filter." },
372
+ "min-tss": { placeholder: "<number>", description: "Minimum TSS threshold." },
373
+ "max-tss": { placeholder: "<number>", description: "Maximum TSS threshold." },
374
+ sort: { placeholder: "<mode>", description: "Command-specific sort mode." },
375
+ "result-limit": { placeholder: "<count>", description: "Limit records after filters are applied." },
376
+ fields: { placeholder: "a,b,c", description: "Project record fields for leaner downstream payloads." },
377
+ "records-only": {
378
+ description: "Return only the envelope and records arrays when supported.",
379
+ },
380
+ view: { placeholder: "current|phases|plans", description: "Plan view to return." },
381
+ date: { placeholder: "YYYY-MM-DD", description: "Target calendar date." },
382
+ details: { description: "Include additional workout/activity detail in the payload." },
383
+ days: { placeholder: "<count>", description: "Relative day window size." },
384
+ limit: { placeholder: "<count>", description: "Upstream or record limit for the command." },
385
+ "history-limit": {
386
+ placeholder: "<count>",
387
+ description: "Maximum FTP history entries to include.",
388
+ },
389
+ "start-date": { placeholder: "YYYY-MM-DD", description: "Personal records window start date." },
390
+ "end-date": { placeholder: "YYYY-MM-DD", description: "Personal records window end date." },
391
+ "row-type": {
392
+ placeholder: "100|101",
393
+ description: "TrainerRoad PR row type to query.",
394
+ },
395
+ "indoor-only": {
396
+ placeholder: "true|false",
397
+ description: "Restrict personal-record queries to indoor rides.",
398
+ },
399
+ slot: {
400
+ placeholder: "<value>",
401
+ description: "Reserved upstream query slot used by the power-records endpoint.",
402
+ },
403
+ search: { placeholder: "<text>", description: "Free-text workout search query." },
404
+ zone: { placeholder: "<name>", description: "Workout zone name filter." },
405
+ "zone-id": { placeholder: "<id>", description: "Workout zone numeric identifier." },
406
+ profile: { placeholder: "<name>", description: "Workout profile name filter." },
407
+ "profile-id": { placeholder: "<id>", description: "Workout profile numeric identifier." },
408
+ outside: {
409
+ placeholder: "true|false",
410
+ description: "Filter or create the outside workout variant where supported.",
411
+ },
412
+ "has-instructions": {
413
+ placeholder: "true|false",
414
+ description: "Filter workouts by whether text instructions exist.",
415
+ },
416
+ "min-duration": { placeholder: "<minutes>", description: "Minimum workout duration." },
417
+ "max-duration": { placeholder: "<minutes>", description: "Maximum workout duration." },
418
+ "min-level": { placeholder: "<number>", description: "Minimum workout progression level." },
419
+ "max-level": { placeholder: "<number>", description: "Maximum workout progression level." },
420
+ "target-duration": {
421
+ placeholder: "<minutes>",
422
+ description: "Target duration used when ranking recommended workouts.",
423
+ },
424
+ "target-tss": {
425
+ placeholder: "<number>",
426
+ description: "Target TSS used when ranking recommended workouts.",
427
+ },
428
+ "target-level": {
429
+ placeholder: "<number>",
430
+ description: "Target progression level used when ranking recommended workouts.",
431
+ },
432
+ count: { placeholder: "<count>", description: "Number of recommendations to return." },
433
+ "candidate-limit": {
434
+ placeholder: "<count>",
435
+ description: "Maximum candidate workouts to score before ranking.",
436
+ },
437
+ "page-size": {
438
+ placeholder: "<count>",
439
+ description: "Upstream workout library page size.",
440
+ },
441
+ id: { placeholder: "<id>", description: "Workout, planned activity, or record identifier." },
442
+ "include-chart": {
443
+ placeholder: "true|false",
444
+ description: "Include workout chart/sample data in workout-details.",
445
+ },
446
+ "chart-point-limit": {
447
+ placeholder: "<count>",
448
+ description: "Maximum chart points returned with --include-chart.",
449
+ },
450
+ "workout-id": {
451
+ placeholder: "<workout-id>",
452
+ description: "TrainerRoad workout library identifier.",
453
+ },
454
+ "dry-run": {
455
+ description: "Preview the write command and exit without mutating the calendar.",
456
+ },
457
+ "alternate-id": {
458
+ placeholder: "<workout-id>",
459
+ description: "Alternate workout ID to apply during replace-workout.",
460
+ },
461
+ "update-duration": {
462
+ placeholder: "true|false",
463
+ description: "Let TrainerRoad update the duration during replace-workout.",
464
+ },
465
+ mode: {
466
+ placeholder: "inside|outside",
467
+ description: "Target workout delivery mode for switch-workout.",
468
+ },
469
+ tz: {
470
+ placeholder: "<IANA timezone>",
471
+ description: "Override local-day bucketing (defaults to TR_TIMEZONE or system timezone).",
472
+ },
473
+ };
474
+
219
475
  function trimFlagPrefix(flag) {
220
476
  return String(flag ?? "").replace(/^--/, "").trim();
221
477
  }
@@ -238,6 +494,7 @@ const SHARED_FLAGS = {
238
494
  jsonAndJsonl: ["json", "jsonl"],
239
495
  agentFilters: AGENT_FILTER_OPTIONS.map((option) => trimFlagPrefix(option.flag)),
240
496
  agentOutput: AGENT_OUTPUT_OPTIONS.map((option) => trimFlagPrefix(option.flag)),
497
+ writeSafety: ["dry-run"],
241
498
  };
242
499
 
243
500
  export const COMMAND_FLAG_ALLOWLIST = {
@@ -460,6 +717,7 @@ export const COMMAND_FLAG_ALLOWLIST = {
460
717
  SHARED_FLAGS.jsonAndJsonl,
461
718
  SHARED_FLAGS.session,
462
719
  SHARED_FLAGS.credentials,
720
+ SHARED_FLAGS.writeSafety,
463
721
  ["workout-id", "date", "outside"],
464
722
  ),
465
723
  "copy-workout": mergeFlagGroups(
@@ -468,6 +726,7 @@ export const COMMAND_FLAG_ALLOWLIST = {
468
726
  SHARED_FLAGS.jsonAndJsonl,
469
727
  SHARED_FLAGS.session,
470
728
  SHARED_FLAGS.credentials,
729
+ SHARED_FLAGS.writeSafety,
471
730
  ["id", "date"],
472
731
  ),
473
732
  "workout-alternates": mergeFlagGroups(
@@ -484,6 +743,7 @@ export const COMMAND_FLAG_ALLOWLIST = {
484
743
  SHARED_FLAGS.jsonAndJsonl,
485
744
  SHARED_FLAGS.session,
486
745
  SHARED_FLAGS.credentials,
746
+ SHARED_FLAGS.writeSafety,
487
747
  ["id", "to"],
488
748
  ),
489
749
  "replace-workout": mergeFlagGroups(
@@ -492,6 +752,7 @@ export const COMMAND_FLAG_ALLOWLIST = {
492
752
  SHARED_FLAGS.jsonAndJsonl,
493
753
  SHARED_FLAGS.session,
494
754
  SHARED_FLAGS.credentials,
755
+ SHARED_FLAGS.writeSafety,
495
756
  ["id", "alternate-id", "update-duration"],
496
757
  ),
497
758
  "switch-workout": mergeFlagGroups(
@@ -500,6 +761,7 @@ export const COMMAND_FLAG_ALLOWLIST = {
500
761
  SHARED_FLAGS.jsonAndJsonl,
501
762
  SHARED_FLAGS.session,
502
763
  SHARED_FLAGS.credentials,
764
+ SHARED_FLAGS.writeSafety,
503
765
  ["id", "mode"],
504
766
  ),
505
767
  logout: mergeFlagGroups(SHARED_FLAGS.help, SHARED_FLAGS.output, SHARED_FLAGS.session),
@@ -191,7 +191,7 @@ export class TrainerRoadClient {
191
191
  async login({
192
192
  username = this.username,
193
193
  password = this.password,
194
- returnPath = "/app/career/quinnsprouse",
194
+ returnPath = username ? `/app/career/${username}` : "/app/career",
195
195
  } = {}) {
196
196
  if (!username || !password) {
197
197
  throw new Error("Username and password are required for login.");