trainerroad-cli 0.1.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/src/cli.mjs ADDED
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import {
7
+ TrainerRoadClient,
8
+ filterFuturePlanned,
9
+ filterPastActivities,
10
+ } from "./trainerroad-client.mjs";
11
+ import {
12
+ applyAgentRecordFilters,
13
+ hasAgentRecordTransforms,
14
+ toRecordsOnlyPayload,
15
+ } from "./lib/agent-filters.mjs";
16
+ import {
17
+ AGENT_FILTER_OPTIONS,
18
+ AGENT_OUTPUT_OPTIONS,
19
+ COMMAND_FLAG_ALLOWLIST,
20
+ COMMANDS,
21
+ FILTERABLE_COMMANDS,
22
+ GLOBAL_NOTES,
23
+ PROJECT_NOTICE,
24
+ } from "./lib/command-manifest.mjs";
25
+ import { commandAnnotations } from "./commands/annotations.mjs";
26
+ import { commandLogin, commandLogout, commandWhoAmI } from "./commands/auth.mjs";
27
+ import { commandCapabilities, commandDiscover, buildDiscoveryPayload } from "./commands/discovery.mjs";
28
+ import { commandEvents } from "./commands/events.mjs";
29
+ import { commandFtp, commandFtpPrediction } from "./commands/ftp.mjs";
30
+ import { commandLevels } from "./commands/levels.mjs";
31
+ import { commandPlan } from "./commands/plan.mjs";
32
+ import { commandPowerRanking, commandPowerRecords } from "./commands/power.mjs";
33
+ import { commandTimeline } from "./commands/timeline.mjs";
34
+ import { commandWeightHistory } from "./commands/weight-history.mjs";
35
+ import { commandFuture, commandPast, commandToday } from "./commands/workouts.mjs";
36
+
37
+ function printGlobalHelp() {
38
+ console.log("trainerroad-cli (unofficial)");
39
+ console.log("");
40
+ console.log("Commands:");
41
+ for (const [name, def] of Object.entries(COMMANDS)) {
42
+ console.log(` ${name.padEnd(13)} ${def.summary}`);
43
+ }
44
+ console.log("");
45
+ console.log("Global notes:");
46
+ for (const note of GLOBAL_NOTES) console.log(` - ${note}`);
47
+ console.log("");
48
+ console.log("Progressive disclosure:");
49
+ console.log(" node src/cli.mjs discover --level 1");
50
+ console.log(" node src/cli.mjs discover --level 2");
51
+ console.log(" node src/cli.mjs discover --command future --level 3 --json");
52
+ console.log("");
53
+ console.log("Examples:");
54
+ console.log(" node src/cli.mjs login --username quinnsprouse --password-stdin");
55
+ console.log(" node src/cli.mjs future --days 30 --details --json");
56
+ console.log(" node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
57
+ console.log(" node src/cli.mjs timeline --target quinnsprouse --public --json");
58
+ console.log(" node src/cli.mjs ftp --target quinnsprouse --public --json");
59
+ }
60
+
61
+ function levenshteinDistance(left, right) {
62
+ const a = left.toLowerCase();
63
+ const b = right.toLowerCase();
64
+ const rows = a.length + 1;
65
+ const cols = b.length + 1;
66
+ const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0));
67
+
68
+ for (let i = 0; i < rows; i += 1) matrix[i][0] = i;
69
+ for (let j = 0; j < cols; j += 1) matrix[0][j] = j;
70
+
71
+ for (let i = 1; i < rows; i += 1) {
72
+ for (let j = 1; j < cols; j += 1) {
73
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
74
+ matrix[i][j] = Math.min(
75
+ matrix[i - 1][j] + 1,
76
+ matrix[i][j - 1] + 1,
77
+ matrix[i - 1][j - 1] + cost,
78
+ );
79
+ }
80
+ }
81
+
82
+ return matrix[a.length][b.length];
83
+ }
84
+
85
+ function getCommandSuggestions(input, max = 3) {
86
+ const value = String(input ?? "").trim().toLowerCase();
87
+ if (!value) return [];
88
+ const commands = Object.keys(COMMANDS);
89
+
90
+ const prefixMatches = commands.filter((name) => name.toLowerCase().startsWith(value));
91
+ if (prefixMatches.length > 0) return prefixMatches.slice(0, max);
92
+
93
+ const ranked = commands
94
+ .map((name) => ({ name, distance: levenshteinDistance(value, name) }))
95
+ .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name));
96
+
97
+ const threshold = Math.max(2, Math.floor(value.length / 3));
98
+ return ranked
99
+ .filter((item) => item.distance <= threshold)
100
+ .slice(0, max)
101
+ .map((item) => item.name);
102
+ }
103
+
104
+ function getFlagSuggestions(input, allowedFlags, max = 3) {
105
+ const value = String(input ?? "").trim().replace(/^--/, "").toLowerCase();
106
+ const candidates = Array.isArray(allowedFlags) ? allowedFlags : Array.from(allowedFlags ?? []);
107
+ if (!value || candidates.length === 0) return [];
108
+
109
+ const prefixMatches = candidates.filter((name) => name.startsWith(value));
110
+ if (prefixMatches.length > 0) return prefixMatches.slice(0, max);
111
+
112
+ const ranked = candidates
113
+ .map((name) => ({ name, distance: levenshteinDistance(value, name) }))
114
+ .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name));
115
+
116
+ const threshold = Math.max(2, Math.floor(value.length / 3));
117
+ return ranked
118
+ .filter((item) => item.distance <= threshold)
119
+ .slice(0, max)
120
+ .map((item) => item.name);
121
+ }
122
+
123
+ function formatUnknownCommandMessage(input) {
124
+ const suggestions = getCommandSuggestions(input);
125
+ const lines = [`unknown command "${input}" for "trainerroad-cli"`];
126
+ if (suggestions.length === 1) {
127
+ lines.push("", "Did you mean this?", ` ${suggestions[0]}`);
128
+ } else if (suggestions.length > 1) {
129
+ lines.push("", "Did you mean one of these?");
130
+ for (const suggestion of suggestions) lines.push(` ${suggestion}`);
131
+ }
132
+ lines.push("", 'Run "trainerroad-cli help" for available commands.');
133
+ return lines.join("\n");
134
+ }
135
+
136
+ function formatUnknownFlagMessage(command, unknownFlags, allowedFlags) {
137
+ const flags = Array.isArray(unknownFlags) ? unknownFlags : [unknownFlags];
138
+ const normalizedAllowed = Array.from(new Set((allowedFlags ?? []).map((flag) => String(flag)))).sort();
139
+ const lines = [
140
+ `unknown flag${flags.length > 1 ? "s" : ""} for "trainerroad-cli ${command}": ${flags.map((flag) => `--${flag}`).join(", ")}`,
141
+ ];
142
+
143
+ for (const flag of flags) {
144
+ const suggestions = getFlagSuggestions(flag, normalizedAllowed);
145
+ if (suggestions.length === 1) {
146
+ lines.push("", `Did you mean --${suggestions[0]} for --${flag}?`);
147
+ } else if (suggestions.length > 1) {
148
+ lines.push("", `Suggestions for --${flag}:`);
149
+ for (const suggestion of suggestions) lines.push(` --${suggestion}`);
150
+ }
151
+ }
152
+
153
+ if (normalizedAllowed.length > 0) {
154
+ lines.push("", `Allowed flags: ${normalizedAllowed.map((flag) => `--${flag}`).join(", ")}`);
155
+ } else {
156
+ lines.push("", "Allowed flags: none");
157
+ }
158
+
159
+ const helpCommand = command === "help" ? "trainerroad-cli help" : `trainerroad-cli help ${command}`;
160
+ lines.push("", `Run "${helpCommand}" for usage.`);
161
+ return lines.join("\n");
162
+ }
163
+
164
+ function validateCommandFlags(command, flags) {
165
+ const allowlist = COMMAND_FLAG_ALLOWLIST[command];
166
+ if (!allowlist) return { unknownFlags: [] };
167
+ const unknownFlags = Object.keys(flags).filter((flag) => !allowlist.has(flag));
168
+ return { unknownFlags, allowlist: Array.from(allowlist) };
169
+ }
170
+
171
+ function printCommandHelp(command, flags = {}) {
172
+ const def = COMMANDS[command];
173
+ if (!def) {
174
+ console.error(formatUnknownCommandMessage(command));
175
+ return 1;
176
+ }
177
+ if (flags.json) {
178
+ const payload = {
179
+ command,
180
+ summary: def.summary,
181
+ usage: def.usage,
182
+ supportsAgentFilters: FILTERABLE_COMMANDS.has(command),
183
+ agentFilterOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_FILTER_OPTIONS : [],
184
+ agentOutputOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_OUTPUT_OPTIONS : [],
185
+ outputModes: ["text", "json", "jsonl"],
186
+ };
187
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
188
+ return 0;
189
+ }
190
+ console.log(`trainerroad-cli ${command} (unofficial)`);
191
+ console.log("");
192
+ console.log(def.summary);
193
+ console.log(PROJECT_NOTICE);
194
+ console.log("");
195
+ console.log("Usage:");
196
+ for (const line of def.usage) console.log(` ${line}`);
197
+ if (FILTERABLE_COMMANDS.has(command)) {
198
+ console.log("");
199
+ console.log("Agent filters:");
200
+ for (const option of AGENT_FILTER_OPTIONS) {
201
+ console.log(` ${option.flag.padEnd(14)} ${option.description}`);
202
+ }
203
+ console.log("Agent output options:");
204
+ for (const option of AGENT_OUTPUT_OPTIONS) {
205
+ console.log(` ${option.flag.padEnd(14)} ${option.description}`);
206
+ }
207
+ }
208
+ return 0;
209
+ }
210
+
211
+ function parseArgs(argv) {
212
+ const command = argv[2] ?? null;
213
+ const args = argv.slice(3);
214
+ const flags = {};
215
+ const positionals = [];
216
+
217
+ for (let i = 0; i < args.length; i += 1) {
218
+ const part = args[i];
219
+ if (!part.startsWith("--")) {
220
+ positionals.push(part);
221
+ continue;
222
+ }
223
+ const key = part.slice(2);
224
+ const next = args[i + 1];
225
+ if (!next || next.startsWith("--")) {
226
+ flags[key] = true;
227
+ continue;
228
+ }
229
+ flags[key] = next;
230
+ i += 1;
231
+ }
232
+
233
+ return { command, flags, positionals };
234
+ }
235
+
236
+ function isoDateShift(days) {
237
+ const date = new Date();
238
+ date.setUTCDate(date.getUTCDate() + days);
239
+ return date.toISOString().slice(0, 10);
240
+ }
241
+
242
+ function toIsoDateFromPlanned(item) {
243
+ return `${String(item.date.year).padStart(4, "0")}-${String(item.date.month).padStart(2, "0")}-${String(item.date.day).padStart(2, "0")}`;
244
+ }
245
+
246
+ function toIsoDate(value) {
247
+ if (typeof value === "string" && value.length >= 10) return value.slice(0, 10);
248
+ return new Date(value).toISOString().slice(0, 10);
249
+ }
250
+
251
+ function normalizeDateOnlyInput(value, fallback) {
252
+ if (value == null || value === "") return fallback;
253
+ const normalized = String(value).trim();
254
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
255
+ throw new Error(`Invalid date "${value}". Expected YYYY-MM-DD.`);
256
+ }
257
+ return normalized;
258
+ }
259
+
260
+ function requireNumber(value, fallback) {
261
+ if (value == null) return fallback;
262
+ const parsed = Number(value);
263
+ if (!Number.isFinite(parsed)) return fallback;
264
+ return parsed;
265
+ }
266
+
267
+ function requirePositiveInteger(value, fallback) {
268
+ const parsed = requireNumber(value, fallback);
269
+ if (!Number.isInteger(parsed) || parsed < 1) return fallback;
270
+ return parsed;
271
+ }
272
+
273
+ function toBoolean(value, fallback = false) {
274
+ if (value == null) return fallback;
275
+ if (typeof value === "boolean") return value;
276
+ if (typeof value === "number") return value !== 0;
277
+ const normalized = String(value).trim().toLowerCase();
278
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
279
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
280
+ return fallback;
281
+ }
282
+
283
+ function isJsonMode(flags) {
284
+ return Boolean(flags.json || flags.jsonl);
285
+ }
286
+
287
+ function normalizeFtpHistory(raw) {
288
+ const records = Array.isArray(raw) ? raw : [];
289
+ return records
290
+ .map((item) => {
291
+ const dateRaw = item?.date ?? item?.Date ?? null;
292
+ const valueRaw = item?.value ?? item?.Value ?? null;
293
+ const value = Number(valueRaw);
294
+ if (!dateRaw || !Number.isFinite(value)) return null;
295
+ return {
296
+ date: new Date(dateRaw).toISOString(),
297
+ dateOnly: toIsoDate(dateRaw),
298
+ value,
299
+ };
300
+ })
301
+ .filter(Boolean)
302
+ .sort((a, b) => a.date.localeCompare(b.date));
303
+ }
304
+
305
+ function getLastItem(values) {
306
+ if (!Array.isArray(values) || values.length === 0) return null;
307
+ return values[values.length - 1];
308
+ }
309
+
310
+ function compactPersonalRecord(record) {
311
+ return {
312
+ seconds: record?.Seconds ?? null,
313
+ watts: record?.Watts ?? null,
314
+ workoutDate: record?.WorkoutDate ?? null,
315
+ workoutSeconds: record?.WorkoutSeconds ?? null,
316
+ workoutGuid: record?.WorkoutGuid ?? null,
317
+ workoutRecordId: record?.WorkoutRecordId ?? null,
318
+ workoutRecordName: record?.WorkoutRecordName ?? null,
319
+ surveyResponse: record?.SurveyResponseTranslated ?? null,
320
+ };
321
+ }
322
+
323
+ function normalizeFitnessThresholds(raw) {
324
+ const rows = Array.isArray(raw) ? raw : [];
325
+ return rows
326
+ .map((item) => {
327
+ const dateRaw = item?.date ?? item?.Date ?? null;
328
+ const valueRaw = item?.value ?? item?.Value ?? null;
329
+ const value = Number(valueRaw);
330
+ if (!dateRaw || !Number.isFinite(value)) return null;
331
+ return {
332
+ id: item?.id ?? item?.Id ?? null,
333
+ date: new Date(dateRaw).toISOString(),
334
+ dateOnly: toIsoDate(dateRaw),
335
+ value,
336
+ isApplied: Boolean(item?.isApplied ?? item?.IsApplied),
337
+ isEnabled: item?.isEnabled ?? item?.IsEnabled ?? null,
338
+ source: item?.source ?? item?.Source ?? null,
339
+ viewed: item?.viewed ?? item?.Viewed ?? null,
340
+ };
341
+ })
342
+ .filter(Boolean)
343
+ .sort((a, b) => a.date.localeCompare(b.date));
344
+ }
345
+
346
+ function dateOnlyDiffDays(fromDateOnly, toDateOnly) {
347
+ const fromMs = Date.parse(`${fromDateOnly}T00:00:00Z`);
348
+ const toMs = Date.parse(`${toDateOnly}T00:00:00Z`);
349
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null;
350
+ return Math.round((toMs - fromMs) / 86_400_000);
351
+ }
352
+
353
+ function countPlannedWorkoutsInRange(plannedActivities, fromDateOnly, toDateOnly) {
354
+ const rows = Array.isArray(plannedActivities) ? plannedActivities : [];
355
+ return rows.filter((item) => {
356
+ const date = toIsoDateFromPlanned(item);
357
+ if (date < fromDateOnly || date > toDateOnly) return false;
358
+ const type = Number(item?.type);
359
+ return item?.workoutId != null || type === 1;
360
+ }).length;
361
+ }
362
+
363
+ function flattenPublicTssDays(publicTss) {
364
+ const weeks = Array.isArray(publicTss?.tssByDay)
365
+ ? publicTss.tssByDay
366
+ : Array.isArray(publicTss?.TssByDay)
367
+ ? publicTss.TssByDay
368
+ : [];
369
+ return weeks
370
+ .flat()
371
+ .filter((day) => day?.date || day?.Date)
372
+ .map((day) => ({
373
+ date: toIsoDate(day.date ?? day.Date),
374
+ tss: day.tss ?? day.Tss ?? 0,
375
+ tssTrainerRoad: day.tssTrainerRoad ?? day.TssTrainerRoad ?? 0,
376
+ tssOther: day.tssOther ?? day.TssOther ?? 0,
377
+ plannedTssTrainerRoad: day.plannedTssTrainerRoad ?? day.PlannedTssTrainerRoad ?? 0,
378
+ plannedTssOther: day.plannedTssOther ?? day.PlannedTssOther ?? 0,
379
+ plannedTssTotal:
380
+ (day.plannedTssTrainerRoad ?? day.PlannedTssTrainerRoad ?? 0) +
381
+ (day.plannedTssOther ?? day.PlannedTssOther ?? 0),
382
+ hasRides: Boolean(day.hasRides ?? day.HasRides),
383
+ }));
384
+ }
385
+
386
+ function sortByDateAsc(days) {
387
+ return [...days].sort((a, b) => a.date.localeCompare(b.date));
388
+ }
389
+
390
+ function sortByDateDesc(days) {
391
+ return [...days].sort((a, b) => b.date.localeCompare(a.date));
392
+ }
393
+
394
+ async function readPasswordFromStdin() {
395
+ const chunks = [];
396
+ for await (const chunk of process.stdin) chunks.push(chunk);
397
+ return Buffer.concat(chunks).toString("utf8").trim();
398
+ }
399
+
400
+ async function writeOutput(payload, flags, textRenderer = null) {
401
+ if (flags.jsonl) {
402
+ const records = Array.isArray(payload?.records) ? payload.records : Array.isArray(payload) ? payload : [];
403
+ const content = records.map((item) => JSON.stringify(item)).join("\n");
404
+ if (flags.output) {
405
+ await fs.writeFile(flags.output, `${content}${content ? "\n" : ""}`, "utf8");
406
+ console.log(`Wrote JSONL to ${flags.output}`);
407
+ return;
408
+ }
409
+ if (content) console.log(content);
410
+ return;
411
+ }
412
+
413
+ if (flags.json || typeof payload !== "string") {
414
+ const content =
415
+ typeof payload === "string" ? payload : `${JSON.stringify(payload, null, 2)}\n`;
416
+ if (flags.output) {
417
+ await fs.writeFile(flags.output, content, "utf8");
418
+ console.log(`Wrote JSON to ${flags.output}`);
419
+ return;
420
+ }
421
+ process.stdout.write(content);
422
+ return;
423
+ }
424
+
425
+ const text = textRenderer ? textRenderer(payload) : String(payload);
426
+ if (flags.output) {
427
+ await fs.writeFile(flags.output, `${text}\n`, "utf8");
428
+ console.log(`Wrote text to ${flags.output}`);
429
+ return;
430
+ }
431
+ console.log(text);
432
+ }
433
+
434
+ async function withClient(flags) {
435
+ const sessionFile =
436
+ flags["session-file"] ??
437
+ process.env.TR_SESSION_FILE ??
438
+ path.resolve(".trainerroad", "session.json");
439
+ const client = new TrainerRoadClient({
440
+ username: flags.username ?? process.env.TR_USERNAME ?? null,
441
+ password: flags.password ?? process.env.TR_PASSWORD ?? null,
442
+ sessionFile,
443
+ });
444
+ await client.loadSession();
445
+ return client;
446
+ }
447
+
448
+ async function tryGetAuthenticatedMemberInfo(client) {
449
+ try {
450
+ return await client.getMemberInfo();
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
455
+
456
+ async function resolveQueryContext(flags) {
457
+ const client = await withClient(flags);
458
+ const authenticatedMemberInfo = await tryGetAuthenticatedMemberInfo(client);
459
+ const forcePublic = Boolean(flags.public);
460
+ const target = flags.target ?? null;
461
+
462
+ const canUsePrivate =
463
+ authenticatedMemberInfo &&
464
+ !forcePublic &&
465
+ (!target || target === authenticatedMemberInfo.username);
466
+
467
+ if (canUsePrivate) {
468
+ const timeline = await client.getTimeline(
469
+ authenticatedMemberInfo.memberId,
470
+ authenticatedMemberInfo.username,
471
+ );
472
+ return {
473
+ mode: "private",
474
+ client,
475
+ authenticatedMemberInfo,
476
+ memberInfo: authenticatedMemberInfo,
477
+ targetUsername: authenticatedMemberInfo.username,
478
+ timeline,
479
+ };
480
+ }
481
+
482
+ const publicUsername = target ?? authenticatedMemberInfo?.username ?? null;
483
+ if (!publicUsername) {
484
+ throw new Error(
485
+ "No target profile available. Use --target <username> for public mode, or login for private mode.",
486
+ );
487
+ }
488
+
489
+ let publicTss;
490
+ try {
491
+ publicTss = await client.getPublicTssByUsername(publicUsername);
492
+ } catch {
493
+ throw new Error(
494
+ `Public profile data is unavailable for "${publicUsername}". The profile may be private, or the username may not exist.`,
495
+ );
496
+ }
497
+
498
+ return {
499
+ mode: "public",
500
+ client,
501
+ authenticatedMemberInfo,
502
+ targetUsername: publicUsername,
503
+ publicTss,
504
+ publicDays: flattenPublicTssDays(publicTss),
505
+ };
506
+ }
507
+
508
+ function requirePrivateContext(context, command) {
509
+ if (context.mode !== "private") {
510
+ throw new Error(
511
+ `${command} requires private authenticated mode. Login first and run without --public/--target for full private data access.`,
512
+ );
513
+ }
514
+ }
515
+
516
+ async function main() {
517
+ const { command, flags, positionals } = parseArgs(process.argv);
518
+
519
+ if (!command) {
520
+ printGlobalHelp();
521
+ process.exit(0);
522
+ }
523
+
524
+ if (!COMMANDS[command]) {
525
+ console.error(formatUnknownCommandMessage(command));
526
+ process.exit(1);
527
+ }
528
+
529
+ const { unknownFlags, allowlist } = validateCommandFlags(command, flags);
530
+ if (unknownFlags.length > 0) {
531
+ console.error(formatUnknownFlagMessage(command, unknownFlags, allowlist));
532
+ process.exit(1);
533
+ }
534
+
535
+ if (command === "help") {
536
+ if (positionals[0]) {
537
+ process.exit(printCommandHelp(positionals[0], flags));
538
+ }
539
+ if (flags.json) {
540
+ const payload = buildDiscoveryPayload(2, null);
541
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
542
+ process.exit(0);
543
+ }
544
+ printGlobalHelp();
545
+ process.exit(0);
546
+ }
547
+
548
+ if (flags.help) {
549
+ process.exit(printCommandHelp(command, flags));
550
+ }
551
+
552
+ const commandDeps = {
553
+ resolveQueryContext,
554
+ requirePrivateContext,
555
+ applyAgentRecordFilters,
556
+ toRecordsOnlyPayload,
557
+ hasAgentRecordTransforms,
558
+ isJsonMode,
559
+ writeOutput,
560
+ requirePositiveInteger,
561
+ requireNumber,
562
+ normalizeDateOnlyInput,
563
+ isoDateShift,
564
+ filterFuturePlanned,
565
+ filterPastActivities,
566
+ sortByDateAsc,
567
+ sortByDateDesc,
568
+ toIsoDateFromPlanned,
569
+ toIsoDate,
570
+ withClient,
571
+ readPasswordFromStdin,
572
+ normalizeFtpHistory,
573
+ getLastItem,
574
+ normalizeFitnessThresholds,
575
+ dateOnlyDiffDays,
576
+ countPlannedWorkoutsInRange,
577
+ toBoolean,
578
+ compactPersonalRecord,
579
+ };
580
+
581
+ switch (command) {
582
+ case "discover":
583
+ await commandDiscover(flags, commandDeps);
584
+ return;
585
+ case "capabilities":
586
+ await commandCapabilities(flags, commandDeps);
587
+ return;
588
+ case "login":
589
+ await commandLogin(flags, commandDeps);
590
+ return;
591
+ case "whoami":
592
+ await commandWhoAmI(flags, commandDeps);
593
+ return;
594
+ case "timeline":
595
+ await commandTimeline(flags, commandDeps);
596
+ return;
597
+ case "events":
598
+ await commandEvents(flags, commandDeps);
599
+ return;
600
+ case "annotations":
601
+ await commandAnnotations(flags, commandDeps);
602
+ return;
603
+ case "levels":
604
+ await commandLevels(flags, commandDeps);
605
+ return;
606
+ case "plan":
607
+ await commandPlan(flags, commandDeps);
608
+ return;
609
+ case "weight-history":
610
+ await commandWeightHistory(flags, commandDeps);
611
+ return;
612
+ case "today":
613
+ await commandToday(flags, commandDeps);
614
+ return;
615
+ case "future":
616
+ await commandFuture(flags, commandDeps);
617
+ return;
618
+ case "past":
619
+ await commandPast(flags, commandDeps);
620
+ return;
621
+ case "ftp":
622
+ await commandFtp(flags, commandDeps);
623
+ return;
624
+ case "ftp-prediction":
625
+ await commandFtpPrediction(flags, commandDeps);
626
+ return;
627
+ case "power-ranking":
628
+ await commandPowerRanking(flags, commandDeps);
629
+ return;
630
+ case "power-records":
631
+ await commandPowerRecords(flags, commandDeps);
632
+ return;
633
+ case "logout":
634
+ await commandLogout(flags, commandDeps);
635
+ return;
636
+ default:
637
+ throw new Error(`Unhandled command: ${command}`);
638
+ }
639
+ }
640
+
641
+ main().catch((error) => {
642
+ const message = String(error?.message ?? error ?? "Unknown error");
643
+ console.error(`Error: ${message}`);
644
+
645
+ const unknownDiscoverCommandMatch = message.match(/^Unknown command for --command:\s*(.+)$/);
646
+ if (unknownDiscoverCommandMatch) {
647
+ const bad = unknownDiscoverCommandMatch[1].trim();
648
+ const suggestions = getCommandSuggestions(bad);
649
+ if (suggestions.length > 0) {
650
+ console.error("Did you mean:");
651
+ for (const suggestion of suggestions) console.error(` ${suggestion}`);
652
+ }
653
+ }
654
+
655
+ if (
656
+ message.includes("requires private authenticated mode") ||
657
+ message.includes("No target profile available")
658
+ ) {
659
+ console.error('Tip: login first: trainerroad-cli login --username <username> --password-stdin');
660
+ }
661
+ if (message.includes("No target profile available")) {
662
+ console.error('Tip: or use public mode: trainerroad-cli <command> --target <username> --public');
663
+ }
664
+ if (message.includes('Invalid --view "')) {
665
+ console.error("Tip: valid plan views are: current, phases, plans");
666
+ }
667
+ if (message.includes('Invalid date "')) {
668
+ console.error("Tip: expected date format is YYYY-MM-DD");
669
+ }
670
+ console.error('Run "trainerroad-cli help" or "trainerroad-cli help <command>" for usage.');
671
+
672
+ if (process.env.TR_CLI_DEBUG === "1" && error?.stack) {
673
+ console.error("");
674
+ console.error(error.stack);
675
+ }
676
+ process.exit(1);
677
+ });