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 +28 -0
- package/README.md +65 -1
- package/package.json +11 -2
- package/src/cli.mjs +120 -37
- package/src/commands/annotation-mutations.mjs +253 -0
- package/src/commands/auth.mjs +3 -2
- package/src/commands/discovery.mjs +9 -8
- package/src/commands/event-mutations.mjs +174 -0
- package/src/commands/plan.mjs +23 -5
- package/src/commands/power.mjs +2 -1
- package/src/commands/workout-image.mjs +99 -0
- package/src/commands/workout-library.mjs +1 -0
- package/src/commands/workout-mutations.mjs +186 -46
- package/src/commands/workout-tools.mjs +104 -41
- package/src/lib/command-manifest.mjs +458 -35
- package/src/lib/planning-normalizers.mjs +110 -4
- package/src/trainerroad-client.mjs +246 -36
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.
|
|
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.
|
|
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("
|
|
70
|
-
console.log("
|
|
71
|
-
console.log("
|
|
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("
|
|
75
|
-
console.log("
|
|
76
|
-
console.log("
|
|
77
|
-
console.log("
|
|
78
|
-
console.log("
|
|
79
|
-
console.log("
|
|
80
|
-
console.log("
|
|
81
|
-
console.log("
|
|
82
|
-
console.log('
|
|
83
|
-
console.log('
|
|
84
|
-
console.log("
|
|
85
|
-
console.log("
|
|
86
|
-
console.log("
|
|
87
|
-
console.log("
|
|
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
|
-
|
|
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("
|
|
229
|
-
|
|
230
|
-
|
|
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("
|
|
233
|
-
for (const
|
|
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
|
+
}
|
package/src/commands/auth.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
export async function commandLogin(flags, deps) {
|
|
2
2
|
const { withClient, readPasswordFromStdin, writeOutput } = deps;
|
|
3
3
|
const client = await withClient(flags);
|
|
4
|
+
const username = flags.username ?? process.env.TR_USERNAME ?? null;
|
|
4
5
|
const password =
|
|
5
6
|
flags["password-stdin"]
|
|
6
7
|
? await readPasswordFromStdin()
|
|
7
8
|
: flags.password ?? process.env.TR_PASSWORD ?? null;
|
|
8
9
|
const result = await client.login({
|
|
9
|
-
username
|
|
10
|
+
username,
|
|
10
11
|
password,
|
|
11
|
-
returnPath: flags["return-path"] ?? "/app/career
|
|
12
|
+
returnPath: flags["return-path"] ?? (username ? `/app/career/${username}` : "/app/career"),
|
|
12
13
|
});
|
|
13
14
|
await writeOutput(result, { ...flags, json: true });
|
|
14
15
|
}
|