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 +10 -0
- package/README.md +19 -0
- package/package.json +3 -1
- package/src/cli.mjs +81 -29
- package/src/commands/auth.mjs +3 -2
- package/src/commands/discovery.mjs +9 -8
- package/src/commands/workout-mutations.mjs +126 -46
- package/src/commands/workout-tools.mjs +103 -41
- package/src/lib/command-manifest.mjs +297 -35
- package/src/trainerroad-client.mjs +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 - 2026-03-25
|
|
4
|
+
|
|
5
|
+
- Added richer command help with examples, required-flag metadata, and machine-readable help payloads.
|
|
6
|
+
- Improved agent-facing failure messages so missing required flags fail fast with actionable retry guidance.
|
|
7
|
+
- Added `--dry-run` previews for calendar mutation commands.
|
|
8
|
+
- Made convergent mutation commands idempotent when the requested end state is already satisfied.
|
|
9
|
+
- Fixed login default return-path handling so it follows the provided username.
|
|
10
|
+
- Added automated tests covering CLI help/error behavior and write-command dry-run/no-op semantics.
|
package/README.md
CHANGED
|
@@ -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.
|
|
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("
|
|
70
|
-
console.log("
|
|
71
|
-
console.log("
|
|
71
|
+
console.log(" trainerroad-cli discover --level 1");
|
|
72
|
+
console.log(" trainerroad-cli discover --level 2");
|
|
73
|
+
console.log(" trainerroad-cli discover --command future --level 3 --json");
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log('Use "trainerroad-cli <command> --help" for command-specific options and examples.');
|
|
72
76
|
console.log("");
|
|
73
77
|
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("
|
|
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
|
-
|
|
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("
|
|
229
|
-
|
|
230
|
-
|
|
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("
|
|
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
|
-
}
|
|
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,
|
package/src/commands/auth.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
export async function commandLogin(flags, deps) {
|
|
2
2
|
const { withClient, readPasswordFromStdin, writeOutput } = deps;
|
|
3
3
|
const client = await withClient(flags);
|
|
4
|
+
const username = flags.username ?? process.env.TR_USERNAME ?? null;
|
|
4
5
|
const password =
|
|
5
6
|
flags["password-stdin"]
|
|
6
7
|
? await readPasswordFromStdin()
|
|
7
8
|
: flags.password ?? process.env.TR_PASSWORD ?? null;
|
|
8
9
|
const result = await client.login({
|
|
9
|
-
username
|
|
10
|
+
username,
|
|
10
11
|
password,
|
|
11
|
-
returnPath: flags["return-path"] ?? "/app/career
|
|
12
|
+
returnPath: flags["return-path"] ?? (username ? `/app/career/${username}` : "/app/career"),
|
|
12
13
|
});
|
|
13
14
|
await writeOutput(result, { ...flags, json: true });
|
|
14
15
|
}
|
|
@@ -12,6 +12,7 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
12
12
|
name,
|
|
13
13
|
summary: def.summary,
|
|
14
14
|
usage: level >= 2 ? def.usage : undefined,
|
|
15
|
+
examples: level >= 2 ? def.examples : undefined,
|
|
15
16
|
supportsAgentFilters: FILTERABLE_COMMANDS.has(name),
|
|
16
17
|
agentFilters: level >= 3 && FILTERABLE_COMMANDS.has(name) ? AGENT_FILTER_OPTIONS : undefined,
|
|
17
18
|
agentOutputOptions:
|
|
@@ -27,11 +28,11 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
27
28
|
level3: "Apply --from/--to/--type/--contains/--min-tss/--max-tss/--sort/--result-limit/--fields.",
|
|
28
29
|
},
|
|
29
30
|
firstSteps: [
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
31
|
+
"trainerroad-cli capabilities --json",
|
|
32
|
+
"trainerroad-cli whoami --json",
|
|
33
|
+
"trainerroad-cli future --days 30 --json",
|
|
34
|
+
"trainerroad-cli today --tz America/New_York --json",
|
|
35
|
+
"trainerroad-cli help future --json",
|
|
35
36
|
],
|
|
36
37
|
commandCount: commandEntries.length,
|
|
37
38
|
commands: commandEntries,
|
|
@@ -42,17 +43,17 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
42
43
|
{
|
|
43
44
|
pattern: "Summarize future workouts in a date window",
|
|
44
45
|
command:
|
|
45
|
-
"
|
|
46
|
+
"trainerroad-cli future --from 2026-03-01 --to 2026-03-31 --fields id,title,tss,date --sort date --json",
|
|
46
47
|
},
|
|
47
48
|
{
|
|
48
49
|
pattern: "Find hard completed rides",
|
|
49
50
|
command:
|
|
50
|
-
"
|
|
51
|
+
"trainerroad-cli past --days 90 --details --min-tss 80 --sort tss-desc --result-limit 20 --jsonl",
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
54
|
pattern: "Extract only fields for downstream tools",
|
|
54
55
|
command:
|
|
55
|
-
"
|
|
56
|
+
"trainerroad-cli today --details --fields recordType,name,started,tss --json",
|
|
56
57
|
},
|
|
57
58
|
];
|
|
58
59
|
}
|
|
@@ -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
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
222
|
-
const
|
|
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
|
-
|
|
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
|
|
282
|
-
const
|
|
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
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
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
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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: ["
|
|
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
|
-
"
|
|
26
|
-
"
|
|
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: ["
|
|
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
|
-
"
|
|
37
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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: ["
|
|
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: ["
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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: ["
|
|
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
|
|
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.");
|