trainerroad-cli 0.2.0 → 0.4.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,28 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 - 2026-09-02
4
+
5
+ - Added `add-annotation`, `remove-annotation`, and `annotation-details`: create time off, illness, injury, and note entries (single or multi-day via `--days` or `--end-date`), remove them by id (no-op when already gone), and read the title and notes that the timeline rows omit. Uses `POST/DELETE /app/api/calendar/annotations` and `GET /app/api/react-calendar/annotation/{id}`, confirmed live.
6
+ - Added `add-event`: creates a race or event via `POST /app/api/calendar/plannedactivities/event` with discipline (by name or id), A/B/C priority, duration, and either a TSS or a 1-10 intensity estimate. Confirmed live. Events are removed with `remove-workout`.
7
+ - Added `remove-workout`: deletes a planned workout or event by planned-activity id via `DELETE /app/api/calendar/plannedactivities/{id}` (confirmed live), with `--dry-run` and a no-op when the record is already gone.
8
+ - Fixed swapped annotation type labels: typeId 2 is illness and typeId 4 is time off (checked against the web app's enum and real calendar entries). Plan-marker ids 5 to 10 are now labelled too.
9
+ - Added `workout-image`: saves a workout's power-profile chart as PNG (default, via the optional `@resvg/resvg-js` package) or SVG. Workout records from `workout-library` and `workout-details` now include `chartUrl`.
10
+ - Fixed `power-records`: the web app moved to `POST /app/api/personal-records/{memberId}`; the old `/for-date-range` path stays as a 404 fallback.
11
+ - Key normalisation now runs per object at every depth, because personal-records nests PascalCase rows inside a camelCase envelope.
12
+ - Help output uses per-command flag descriptions, so shared names like `--type` and `--days` read correctly for each command.
13
+ - Write commands report an `adaptiveTraining` note, and the README explains how Adaptive Training reacts to calendar changes and how an agent should read an athlete's situation.
14
+ - Fixed `login`: TrainerRoad replaced the server-rendered login form with a React app, so the token scrape failed. `login` now posts JSON to `/app/api/login/login` the way the web app does, and falls back to the old form flow if that route ever disappears. A bad password now fails with "TrainerRoad rejected the username or password" instead of a redirect error.
15
+ - Fixed `plan` returning 404s: plan-builder endpoints are requested by numeric `memberId` instead of username (the username stays in the referer). The career-summary endpoint had the same problem and now takes a `memberId` too.
16
+ - Made a missing `current-custom-plan` (HTTP 404) non-fatal. The web app no longer calls that endpoint at all, so when it is absent `currentPlan` is derived from `all-user-plans` (the plan whose start/end window contains today) and its matching `plan-phases` rows. `currentPlan.source` reports which path produced it, and `currentPhaseName` names the active phase.
17
+ - Every API request now sends `trainerroad-jsonformat: camel-case` by default, and a PascalCase payload is normalised to camelCase if the header is ever ignored, so a dropped header or a casing change cannot blank the output.
18
+ - Request failures throw `HttpError` with `status`, `statusText`, `path`, and `payload` (message unchanged).
19
+ - Added tests for the JSON login flow and its fallback, memberId-keyed URLs, the format header, PascalCase normalisation, non-fatal 404 handling, and date-window current-plan derivation.
20
+
21
+ ## 0.3.0 - 2026-03-25
22
+
23
+ - Added richer command help with examples, required-flag metadata, and machine-readable help payloads.
24
+ - Improved agent-facing failure messages so missing required flags fail fast with actionable retry guidance.
25
+ - Added `--dry-run` previews for calendar mutation commands.
26
+ - Made convergent mutation commands idempotent when the requested end state is already satisfied.
27
+ - Fixed login default return-path handling so it follows the provided username.
28
+ - Added automated tests covering CLI help/error behavior and write-command dry-run/no-op semantics.
package/README.md CHANGED
@@ -26,6 +26,42 @@ It can also perform a small set of verified calendar writes for planned workouts
26
26
  - list TrainerRoad alternate workout options
27
27
  - replace a workout with a specific alternate
28
28
  - switch a workout between inside and outside
29
+ - remove a planned workout or event
30
+ - add a race or event with discipline, priority, and a TSS or intensity estimate
31
+ - add and remove calendar annotations: time off, illness, injury, notes
32
+ - save a workout's power-profile chart as PNG or SVG
33
+
34
+ ## Agent-Friendly Behavior
35
+
36
+ - Non-interactive by default. Inputs are flags or stdin, not prompts.
37
+ - Progressive disclosure. Use `trainerroad-cli help <command>` or `trainerroad-cli discover`.
38
+ - Command help includes concrete examples and flag descriptions.
39
+ - Write commands support `--dry-run` previews.
40
+ - Common retry cases are idempotent no-ops instead of duplicate calendar writes.
41
+ - Every write returns `before`/`after` (or the created record) so the caller can verify without a second call.
42
+
43
+ ## How TrainerRoad Reacts To Calendar Changes
44
+
45
+ TrainerRoad's Adaptive Training treats the calendar as input. Agents managing an athlete's plan should expect:
46
+
47
+ - Removing or skipping a planned workout can cause TrainerRoad to rebuild the upcoming plan around the gap.
48
+ - Adding a workout, or marking time off, illness, or injury, can likewise shift the surrounding planned workouts.
49
+ - Planned workouts carry `recommendationReason`, `adaptationLocked`, and `adaptationAltered` fields that show whether TrainerRoad chose or altered them.
50
+ - After any write, re-read `future --days 14 --details` before deciding the next step. Do not assume the calendar looks the way it did before the write.
51
+
52
+ Write commands echo this in an `adaptiveTraining` field so it is visible in machine-readable output.
53
+
54
+ ## Reading The Athlete's Situation
55
+
56
+ A useful order for an agent building a picture of how training is going:
57
+
58
+ 1. `whoami` for FTP, weight, and timezone.
59
+ 2. `plan --view current` for the plan, its phases, and the phase the athlete is in today.
60
+ 3. `past --days 28 --details` and `future --days 14 --details` for what happened and what is scheduled.
61
+ 4. `levels` for progression levels by zone, and `ftp-prediction` for where FTP is heading.
62
+ 5. `annotations --from <date>` for time off, illness, and injury, and `annotation-details --id` for the notes behind them.
63
+ 6. `events` for upcoming races.
64
+ 7. `workout-image --id <workout-id>` when a picture of a workout's intervals helps explain it.
29
65
 
30
66
  ## Install
31
67
 
@@ -82,6 +118,7 @@ trainerroad-cli workout-library --zone "Endurance" --profile "Sustained Power" -
82
118
  trainerroad-cli workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json
83
119
  trainerroad-cli workout-details --id 18128 --include-chart --json
84
120
  trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --json
121
+ trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --dry-run
85
122
  trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
86
123
  ```
87
124
 
@@ -97,16 +134,34 @@ Then use that planned activity ID:
97
134
 
98
135
  ```bash
99
136
  trainerroad-cli workout-alternates --id <planned-activity-id> --category easier --json
137
+ trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --dry-run
100
138
  trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --json
101
139
  trainerroad-cli replace-workout --id <planned-activity-id> --alternate-id <workout-id> --json
102
140
  trainerroad-cli switch-workout --id <planned-activity-id> --mode outside --json
103
141
  trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
142
+ trainerroad-cli remove-workout --id <planned-activity-id> --dry-run
104
143
  ```
105
144
 
106
145
  `copy-workout` is the reliable way to place an existing planned workout on another date.
107
146
  `add-workout` exists, but TrainerRoad's add endpoints are still inconsistent and may fail even after retry/reconciliation.
108
147
 
109
- 4. Discover all commands
148
+ 4. Events, annotations, and images
149
+
150
+ ```bash
151
+ trainerroad-cli add-event --name "Black Fork" --date 2027-05-01 --discipline gravel --priority A --duration 300 --tss 340 --dry-run
152
+ trainerroad-cli add-event --name "Tuesday crit" --date 2026-10-06 --discipline criterium --priority C --duration 60 --intensity 9 --json
153
+ trainerroad-cli remove-workout --id <planned-activity-id> --json # events and workouts share this
154
+ trainerroad-cli add-annotation --type time-off --date 2026-09-21 --days 3 --title "Travel" --dry-run
155
+ trainerroad-cli add-annotation --type illness --date 2026-09-21 --end-date 2026-09-23 --notes "Head cold" --json
156
+ trainerroad-cli annotation-details --id <annotation-id> --json
157
+ trainerroad-cli remove-annotation --id <annotation-id> --json
158
+ trainerroad-cli workout-image --id 1592808 --file fishers.png
159
+ trainerroad-cli workout-image --id 1592808 --format svg --file fishers.svg
160
+ ```
161
+
162
+ PNG output uses the optional `@resvg/resvg-js` package. If it is not installed, use `--format svg`.
163
+
164
+ 5. Discover all commands
110
165
 
111
166
  ```bash
112
167
  trainerroad-cli help
@@ -130,6 +185,15 @@ Use `--target <username>` and/or `--public` for public mode queries.
130
185
  - `--records-only`: lighter record payloads
131
186
  - `--tz <IANA timezone>`: localize day boundaries/timestamps (defaults to `TR_TIMEZONE` or system timezone)
132
187
 
188
+ ## Help
189
+
190
+ ```bash
191
+ trainerroad-cli help
192
+ trainerroad-cli help future
193
+ trainerroad-cli future --help
194
+ trainerroad-cli help move-workout --json
195
+ ```
196
+
133
197
  ## Security
134
198
 
135
199
  - 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.4.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",
@@ -43,7 +45,11 @@
43
45
  "move-workout": "node src/cli.mjs move-workout",
44
46
  "replace-workout": "node src/cli.mjs replace-workout",
45
47
  "switch-workout": "node src/cli.mjs switch-workout",
46
- "logout": "node src/cli.mjs logout"
48
+ "logout": "node src/cli.mjs logout",
49
+ "workout-image": "node src/cli.mjs workout-image",
50
+ "add-annotation": "node src/cli.mjs add-annotation",
51
+ "remove-annotation": "node src/cli.mjs remove-annotation",
52
+ "annotation-details": "node src/cli.mjs annotation-details"
47
53
  },
48
54
  "keywords": [
49
55
  "trainerroad",
@@ -58,5 +64,8 @@
58
64
  "type": "module",
59
65
  "dependencies": {
60
66
  "playwright": "^1.58.2"
67
+ },
68
+ "optionalDependencies": {
69
+ "@resvg/resvg-js": "^2.6.2"
61
70
  }
62
71
  }
package/src/cli.mjs CHANGED
@@ -17,7 +17,10 @@ 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,
23
+ COMMAND_FLAG_DETAILS,
21
24
  FILTERABLE_COMMANDS,
22
25
  GLOBAL_NOTES,
23
26
  PROJECT_NOTICE,
@@ -41,7 +44,15 @@ import {
41
44
  commandReplaceWorkout,
42
45
  commandSwitchWorkout,
43
46
  commandWorkoutAlternates,
47
+ commandRemoveWorkout,
44
48
  } from "./commands/workout-mutations.mjs";
49
+ import {
50
+ commandAddAnnotation,
51
+ commandAnnotationDetails,
52
+ commandRemoveAnnotation,
53
+ } from "./commands/annotation-mutations.mjs";
54
+ import { commandWorkoutImage } from "./commands/workout-image.mjs";
55
+ import { commandAddEvent } from "./commands/event-mutations.mjs";
45
56
  import { commandFuture, commandPast, commandToday } from "./commands/workouts.mjs";
46
57
  import {
47
58
  formatDateTimeInTimeZone,
@@ -66,25 +77,27 @@ function printGlobalHelp() {
66
77
  for (const note of GLOBAL_NOTES) console.log(` - ${note}`);
67
78
  console.log("");
68
79
  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");
80
+ console.log(" trainerroad-cli discover --level 1");
81
+ console.log(" trainerroad-cli discover --level 2");
82
+ console.log(" trainerroad-cli discover --command future --level 3 --json");
83
+ console.log("");
84
+ console.log('Use "trainerroad-cli <command> --help" for command-specific options and examples.');
72
85
  console.log("");
73
86
  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");
87
+ console.log(" trainerroad-cli login --username quinnsprouse --password-stdin");
88
+ console.log(" trainerroad-cli future --days 30 --details --json");
89
+ console.log(" trainerroad-cli today --tz America/New_York --json");
90
+ console.log(" trainerroad-cli future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
91
+ console.log(" trainerroad-cli timeline --target quinnsprouse --public --json");
92
+ console.log(" trainerroad-cli ftp --target quinnsprouse --public --json");
93
+ console.log(" trainerroad-cli move-workout --id <planned-id> --to 2026-03-13 --dry-run");
94
+ console.log(" trainerroad-cli workout-alternates --id <planned-id> --category easier --json");
95
+ console.log(' trainerroad-cli workout-library --zone "Endurance" --profile "Sustained Power" --min-duration 45 --max-duration 75 --json');
96
+ console.log(' trainerroad-cli workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json');
97
+ console.log(" trainerroad-cli train-now --duration 60 --json");
98
+ console.log(" trainerroad-cli workout-details --id 18128 --include-chart --json");
99
+ console.log(" trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --dry-run");
100
+ console.log(" trainerroad-cli copy-workout --id <planned-id> --date 2026-03-16 --json");
88
101
  }
89
102
 
90
103
  function levenshteinDistance(left, right) {
@@ -197,6 +210,49 @@ function validateCommandFlags(command, flags) {
197
210
  return { unknownFlags, allowlist: Array.from(allowlist) };
198
211
  }
199
212
 
213
+ function flagDetail(command, name) {
214
+ return COMMAND_FLAG_DETAILS[command]?.[name] ?? FLAG_DETAILS[name] ?? {};
215
+ }
216
+
217
+ function formatFlagLabel(command, name) {
218
+ const detail = flagDetail(command, name);
219
+ return `--${name}${detail.placeholder ? ` ${detail.placeholder}` : ""}`;
220
+ }
221
+
222
+ function getCommandHelpOptions(command) {
223
+ const allowlist = Array.from(COMMAND_FLAG_ALLOWLIST[command] ?? []);
224
+ const requiredFlags = new Set(COMMAND_REQUIRED_FLAGS[command] ?? []);
225
+ return allowlist.map((name) => ({
226
+ name,
227
+ label: formatFlagLabel(command, name),
228
+ description: flagDetail(command, name).description ?? "No description available.",
229
+ required: requiredFlags.has(name),
230
+ }));
231
+ }
232
+
233
+ function formatMissingRequiredFlagMessage(command, flagName) {
234
+ const def = COMMANDS[command];
235
+ const requiredFlags = (COMMAND_REQUIRED_FLAGS[command] ?? []).map((name) => `--${name}`);
236
+ const usageLines = Array.isArray(def?.usage) ? def.usage : [];
237
+ const exampleLines = Array.isArray(def?.examples) ? def.examples : [];
238
+ const lines = [`Missing required flag --${flagName} for "trainerroad-cli ${command}".`];
239
+
240
+ if (requiredFlags.length > 0) {
241
+ lines.push("", `Required flags: ${requiredFlags.join(", ")}`);
242
+ }
243
+ if (usageLines.length > 0) {
244
+ lines.push("", "Usage:");
245
+ for (const line of usageLines) lines.push(` ${line}`);
246
+ }
247
+ if (exampleLines.length > 0) {
248
+ lines.push("", "Examples:");
249
+ for (const line of exampleLines) lines.push(` ${line}`);
250
+ }
251
+
252
+ lines.push("", `Run "trainerroad-cli help ${command}" for more detail.`);
253
+ return lines.join("\n");
254
+ }
255
+
200
256
  function printCommandHelp(command, flags = {}) {
201
257
  const def = COMMANDS[command];
202
258
  if (!def) {
@@ -204,11 +260,16 @@ function printCommandHelp(command, flags = {}) {
204
260
  return 1;
205
261
  }
206
262
  if (flags.json) {
263
+ const options = getCommandHelpOptions(command);
207
264
  const payload = {
208
265
  command,
209
266
  summary: def.summary,
210
267
  usage: def.usage,
211
- timezoneOption: "--tz <IANA timezone> (defaults to TR_TIMEZONE or system timezone)",
268
+ examples: def.examples ?? [],
269
+ options,
270
+ requiredFlags: options.filter((option) => option.required).map((option) => option.name),
271
+ nonInteractive: true,
272
+ supportsDryRun: options.some((option) => option.name === "dry-run"),
212
273
  supportsAgentFilters: FILTERABLE_COMMANDS.has(command),
213
274
  agentFilterOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_FILTER_OPTIONS : [],
214
275
  agentOutputOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_OUTPUT_OPTIONS : [],
@@ -225,18 +286,15 @@ function printCommandHelp(command, flags = {}) {
225
286
  console.log("Usage:");
226
287
  for (const line of def.usage) console.log(` ${line}`);
227
288
  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)) {
289
+ console.log("Options:");
290
+ for (const option of getCommandHelpOptions(command)) {
291
+ const suffix = option.required ? " [required]" : "";
292
+ console.log(` ${option.label.padEnd(34)} ${option.description}${suffix}`);
293
+ }
294
+ if (Array.isArray(def.examples) && def.examples.length > 0) {
231
295
  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
- }
296
+ console.log("Examples:");
297
+ for (const line of def.examples) console.log(` ${line}`);
240
298
  }
241
299
  return 0;
242
300
  }
@@ -360,14 +418,14 @@ function getLastItem(values) {
360
418
 
361
419
  function compactPersonalRecord(record) {
362
420
  return {
363
- seconds: record?.Seconds ?? null,
364
- watts: record?.Watts ?? null,
365
- workoutDate: record?.WorkoutDate ?? null,
366
- workoutSeconds: record?.WorkoutSeconds ?? null,
367
- workoutGuid: record?.WorkoutGuid ?? null,
368
- workoutRecordId: record?.WorkoutRecordId ?? null,
369
- workoutRecordName: record?.WorkoutRecordName ?? null,
370
- surveyResponse: record?.SurveyResponseTranslated ?? null,
421
+ seconds: record?.seconds ?? record?.Seconds ?? null,
422
+ watts: record?.watts ?? record?.Watts ?? null,
423
+ workoutDate: record?.workoutDate ?? record?.WorkoutDate ?? null,
424
+ workoutSeconds: record?.workoutSeconds ?? record?.WorkoutSeconds ?? null,
425
+ workoutGuid: record?.workoutGuid ?? record?.WorkoutGuid ?? null,
426
+ workoutRecordId: record?.workoutRecordId ?? record?.WorkoutRecordId ?? null,
427
+ workoutRecordName: record?.workoutRecordName ?? record?.WorkoutRecordName ?? null,
428
+ surveyResponse: record?.surveyResponseTranslated ?? record?.SurveyResponseTranslated ?? null,
371
429
  };
372
430
  }
373
431
 
@@ -643,6 +701,13 @@ async function main() {
643
701
  summarizeActivityTime,
644
702
  withClient,
645
703
  readPasswordFromStdin,
704
+ requireFlag: (commandName, incomingFlags, flagName) => {
705
+ const value = incomingFlags[flagName];
706
+ if (value === undefined || value === null || value === "") {
707
+ throw new Error(formatMissingRequiredFlagMessage(commandName, flagName));
708
+ }
709
+ return value;
710
+ },
646
711
  normalizeFtpHistory,
647
712
  getLastItem,
648
713
  normalizeFitnessThresholds,
@@ -734,6 +799,24 @@ async function main() {
734
799
  case "switch-workout":
735
800
  await commandSwitchWorkout(flags, commandDeps);
736
801
  return;
802
+ case "add-event":
803
+ await commandAddEvent(flags, commandDeps);
804
+ return;
805
+ case "remove-workout":
806
+ await commandRemoveWorkout(flags, commandDeps);
807
+ return;
808
+ case "workout-image":
809
+ await commandWorkoutImage(flags, commandDeps);
810
+ return;
811
+ case "annotation-details":
812
+ await commandAnnotationDetails(flags, commandDeps);
813
+ return;
814
+ case "add-annotation":
815
+ await commandAddAnnotation(flags, commandDeps);
816
+ return;
817
+ case "remove-annotation":
818
+ await commandRemoveAnnotation(flags, commandDeps);
819
+ return;
737
820
  case "logout":
738
821
  await commandLogout(flags, commandDeps);
739
822
  return;
@@ -0,0 +1,253 @@
1
+ import {
2
+ ANNOTATION_TYPE_IDS,
3
+ ANNOTATION_TYPE_LABELS,
4
+ compactAnnotationDetail,
5
+ } from "../lib/planning-normalizers.mjs";
6
+ import { shiftDateOnly } from "../lib/timezone.mjs";
7
+ import { isHttpStatus } from "../trainerroad-client.mjs";
8
+
9
+ const DAY_SECONDS = 86_400;
10
+
11
+ function dateOnlyDiffDays(fromDateOnly, toDateOnly) {
12
+ const [fy, fm, fd] = fromDateOnly.split("-").map(Number);
13
+ const [ty, tm, td] = toDateOnly.split("-").map(Number);
14
+ return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / DAY_SECONDS / 1000);
15
+ }
16
+
17
+ // TrainerRoad's Adaptive Training reacts to calendar changes: time off, illness, and injury
18
+ // entries can prompt it to adjust the surrounding planned workouts. Surface that to agents.
19
+ const ADAPTIVE_NOTE =
20
+ "TrainerRoad may adapt nearby planned workouts in response to this change. Re-read `future` afterwards.";
21
+
22
+ function resolveAnnotationType(value) {
23
+ if (value === undefined || value === null || value === "") return null;
24
+ const raw = String(value).trim().toLowerCase();
25
+ if (/^\d+$/.test(raw)) {
26
+ const typeId = Number(raw);
27
+ return { typeId, typeLabel: ANNOTATION_TYPE_LABELS[typeId] ?? `type-${typeId}` };
28
+ }
29
+ const typeId = ANNOTATION_TYPE_IDS[raw] ?? ANNOTATION_TYPE_IDS[raw.replace(/[\s_]+/g, "-")];
30
+ if (typeId === undefined) return null;
31
+ return { typeId, typeLabel: ANNOTATION_TYPE_LABELS[typeId] };
32
+ }
33
+
34
+ function validTypeNames() {
35
+ return Object.keys(ANNOTATION_TYPE_IDS).join(", ");
36
+ }
37
+
38
+ function annotationTitleFor(typeLabel) {
39
+ return typeLabel
40
+ .split("-")
41
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
42
+ .join(" ");
43
+ }
44
+
45
+ async function requirePrivateMember(flags, deps) {
46
+ const { withClient } = deps;
47
+ const client = await withClient(flags);
48
+ let memberInfo;
49
+ try {
50
+ memberInfo = await client.getMemberInfo();
51
+ } catch {
52
+ throw new Error(
53
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
54
+ );
55
+ }
56
+ return { client, memberInfo };
57
+ }
58
+
59
+ async function annotationIdsOnCalendar(client, memberInfo) {
60
+ const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
61
+ const rows = Array.isArray(timeline?.annotations) ? timeline.annotations : [];
62
+ return new Set(rows.map((row) => String(row?.id)).filter((id) => id && id !== "undefined"));
63
+ }
64
+
65
+ export async function commandAnnotationDetails(flags, deps) {
66
+ const { isJsonMode, requireFlag, writeOutput } = deps;
67
+ const annotationId = String(requireFlag("annotation-details", flags, "id"));
68
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
69
+ const raw = await client.getAnnotation(annotationId, memberInfo.username);
70
+ const annotation = compactAnnotationDetail(raw);
71
+
72
+ const payload = {
73
+ generatedAt: new Date().toISOString(),
74
+ command: "annotation-details",
75
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
76
+ query: { annotationId },
77
+ annotation,
78
+ raw: flags.full ? raw : undefined,
79
+ };
80
+
81
+ if (!isJsonMode(flags)) {
82
+ await writeOutput(payload, flags, (value) => {
83
+ const a = value.annotation;
84
+ return [
85
+ `${a.typeLabel} ${a.dateOnly}..${a.endDateOnly} (${a.durationDays}d) | id=${a.id}`,
86
+ `title: ${a.title ?? "(none)"}`,
87
+ `notes: ${a.text ?? "(none)"}`,
88
+ ].join("\n");
89
+ });
90
+ return;
91
+ }
92
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
93
+ }
94
+
95
+ export async function commandAddAnnotation(flags, deps) {
96
+ const { isJsonMode, requireFlag, toBoolean, normalizeDateOnlyInput, requirePositiveInteger, writeOutput } = deps;
97
+ const dryRun = toBoolean(flags["dry-run"], false);
98
+ const type = resolveAnnotationType(requireFlag("add-annotation", flags, "type"));
99
+ if (!type) {
100
+ throw new Error(`Invalid --type "${flags.type}". Expected one of: ${validTypeNames()}, or a numeric typeId.`);
101
+ }
102
+ const date = normalizeDateOnlyInput(requireFlag("add-annotation", flags, "date"), null);
103
+ if (!date) throw new Error(`Invalid --date "${flags.date}". Expected YYYY-MM-DD.`);
104
+
105
+ let durationDays = requirePositiveInteger(flags.days, 1);
106
+ if (flags["end-date"] !== undefined && flags["end-date"] !== null && flags["end-date"] !== "") {
107
+ const endDate = normalizeDateOnlyInput(flags["end-date"], null);
108
+ if (!endDate) throw new Error(`Invalid --end-date "${flags["end-date"]}". Expected YYYY-MM-DD.`);
109
+ const diff = dateOnlyDiffDays(date, endDate);
110
+ if (diff === null || diff < 0) {
111
+ throw new Error(`--end-date ${endDate} is before --date ${date}.`);
112
+ }
113
+ durationDays = diff + 1;
114
+ }
115
+ const endDateOnly = shiftDateOnly(date, durationDays - 1);
116
+
117
+ const title = flags.title !== undefined && flags.title !== null && String(flags.title) !== ""
118
+ ? String(flags.title)
119
+ : annotationTitleFor(type.typeLabel);
120
+ const text = flags.notes !== undefined && flags.notes !== null ? String(flags.notes) : "";
121
+ const colorId = requirePositiveInteger(flags["color-id"], 2);
122
+
123
+ const request = {
124
+ date,
125
+ timeOfDay: null,
126
+ duration: durationDays * DAY_SECONDS,
127
+ title,
128
+ text,
129
+ typeId: type.typeId,
130
+ colorId,
131
+ };
132
+ const preview = {
133
+ typeId: type.typeId,
134
+ typeLabel: type.typeLabel,
135
+ dateOnly: date,
136
+ endDateOnly,
137
+ durationDays,
138
+ title,
139
+ text,
140
+ colorId,
141
+ };
142
+
143
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
144
+
145
+ const base = {
146
+ generatedAt: new Date().toISOString(),
147
+ command: "add-annotation",
148
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
149
+ query: preview,
150
+ adaptiveTraining: ADAPTIVE_NOTE,
151
+ };
152
+
153
+ if (dryRun) {
154
+ const payload = {
155
+ ...base,
156
+ dryRun: true,
157
+ annotation: null,
158
+ request,
159
+ message: `Would add ${type.typeLabel} "${title}" on ${date}${durationDays > 1 ? ` through ${endDateOnly}` : ""}.`,
160
+ };
161
+ if (!isJsonMode(flags)) {
162
+ await writeOutput(payload, flags, (value) => `${value.message}\nNo changes made.`);
163
+ return;
164
+ }
165
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
166
+ return;
167
+ }
168
+
169
+ const before = await annotationIdsOnCalendar(client, memberInfo);
170
+ await client.createAnnotation(request, memberInfo.username);
171
+ const after = await annotationIdsOnCalendar(client, memberInfo);
172
+ const createdIds = [...after].filter((id) => !before.has(id));
173
+
174
+ let annotation = null;
175
+ for (const id of createdIds) {
176
+ const detail = compactAnnotationDetail(await client.getAnnotation(id, memberInfo.username));
177
+ if (detail.typeId === type.typeId && detail.dateOnly === date) {
178
+ annotation = detail;
179
+ break;
180
+ }
181
+ }
182
+
183
+ const payload = {
184
+ ...base,
185
+ dryRun: false,
186
+ annotation,
187
+ request,
188
+ message: annotation
189
+ ? `Added ${annotation.typeLabel} "${annotation.title}" on ${annotation.dateOnly}${annotation.durationDays > 1 ? ` through ${annotation.endDateOnly}` : ""} (id=${annotation.id}).`
190
+ : "TrainerRoad accepted the annotation but it could not be located on the calendar afterwards. Run `annotations` to inspect.",
191
+ };
192
+
193
+ if (!isJsonMode(flags)) {
194
+ await writeOutput(payload, flags, (value) => `${value.message}\n${value.adaptiveTraining}`);
195
+ return;
196
+ }
197
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
198
+ }
199
+
200
+ export async function commandRemoveAnnotation(flags, deps) {
201
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
202
+ const dryRun = toBoolean(flags["dry-run"], false);
203
+ const annotationId = String(requireFlag("remove-annotation", flags, "id"));
204
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
205
+
206
+ let before = null;
207
+ try {
208
+ before = compactAnnotationDetail(await client.getAnnotation(annotationId, memberInfo.username));
209
+ } catch (error) {
210
+ if (!isHttpStatus(error, 404)) throw error;
211
+ }
212
+ const noop = before === null;
213
+
214
+ const base = {
215
+ generatedAt: new Date().toISOString(),
216
+ command: "remove-annotation",
217
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
218
+ query: { annotationId },
219
+ before,
220
+ adaptiveTraining: ADAPTIVE_NOTE,
221
+ };
222
+
223
+ if (dryRun || noop) {
224
+ const payload = {
225
+ ...base,
226
+ dryRun,
227
+ noop,
228
+ message: noop
229
+ ? `No annotation with id ${annotationId} exists on the calendar.`
230
+ : `Would remove ${before.typeLabel} "${before.title}" on ${before.dateOnly}.`,
231
+ };
232
+ if (!isJsonMode(flags)) {
233
+ await writeOutput(payload, flags, (value) => (value.noop ? value.message : `${value.message}\nNo changes made.`));
234
+ return;
235
+ }
236
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
237
+ return;
238
+ }
239
+
240
+ await client.deleteAnnotation(annotationId, memberInfo.username);
241
+ const payload = {
242
+ ...base,
243
+ dryRun: false,
244
+ noop: false,
245
+ message: `Removed ${before.typeLabel} "${before.title}" on ${before.dateOnly} (id=${annotationId}).`,
246
+ };
247
+
248
+ if (!isJsonMode(flags)) {
249
+ await writeOutput(payload, flags, (value) => `${value.message}\n${value.adaptiveTraining}`);
250
+ return;
251
+ }
252
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
253
+ }
@@ -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
  }