@trainheroic-unofficial/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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/cli.mjs +1276 -0
  4. package/package.json +39 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alan Cohen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @trainheroic-unofficial/cli
2
+
3
+ A command-line tool for the TrainHeroic coaching API. Takes credentials from the environment, prints JSON.
4
+
5
+ Part of the [trainheroic-unofficial](../../README.md) workspace.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @trainheroic-unofficial/cli
11
+ # or: npx @trainheroic-unofficial/cli <command>
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ export TRAINHEROIC_EMAIL="coach@example.com"
18
+ export TRAINHEROIC_PASSWORD="..."
19
+
20
+ trainheroic whoami
21
+ trainheroic exercise resolve "Back Squat"
22
+ trainheroic workout build --program 12345 --date 2026-6-22 --file day.json
23
+ ```
24
+
25
+ During development, run from source with `pnpm start <args>`; after `pnpm build`, the
26
+ `trainheroic` binary (`dist/cli.mjs`) does the same.
27
+
28
+ Run `trainheroic` with no command to print the full help. Commands group into reads
29
+ (`whoami`, `athletes`, `programs`, `teams`, `program <id>`, and the rest), a raw `request`
30
+ escape hatch, exercise-library operations (`resolve`, `search`, `get`, `sync`, `create`,
31
+ `forget`, `stats`), the workout lifecycle (`build`, `read`, `publish`, `remove`), and
32
+ messaging (`list`, `read`, `draft`, `send`, `delete`).
33
+
34
+ ## Conventions
35
+
36
+ - Output is JSON on stdout; errors go to stderr with a non-zero exit code.
37
+ - Actions that touch an athlete or delete data require an explicit `--yes`. That covers
38
+ `workout publish`, `workout remove`, `exercise forget`, `message send`, `message delete`,
39
+ and `workout build --publish`.
40
+ - JSON input can be passed inline, with `--file <path>`, or piped on stdin. Inputs are
41
+ validated against the shared schemas from `@trainheroic-unofficial/dto`.
42
+ - The session token and the exercise library are cached under `~/.trainheroic/`. The library
43
+ file is the same shape the local server uses, so the two share a cache.
44
+
45
+ ## Develop
46
+
47
+ ```bash
48
+ pnpm start whoami # tsx src/cli.ts
49
+ pnpm build # tsdown -> dist/cli.mjs
50
+ pnpm typecheck
51
+ pnpm test
52
+ pnpm exec vitest run test/parse.test.ts
53
+ ```
package/dist/cli.mjs ADDED
@@ -0,0 +1,1276 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import process from "node:process";
4
+ import { parseArgs } from "node:util";
5
+ import { z } from "zod";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ //#region ../dto/src/common.ts
9
+ /** An entity id as it arrives over the wire — a number or a numeric string. */
10
+ const idSchema = z.union([z.string(), z.number()]);
11
+ z.union([z.number().int(), z.string().regex(/^\d+$/u)]);
12
+ //#endregion
13
+ //#region ../dto/src/exercise.ts
14
+ /** Body for creating a custom exercise; extra fields the API accepts are preserved. */
15
+ const exerciseCreateSchema = z.looseObject({
16
+ title: z.string().min(1),
17
+ param_1_type: z.number().optional(),
18
+ param_2_type: z.number().optional()
19
+ });
20
+ z.object({
21
+ streamId: idSchema,
22
+ text: z.string().min(1),
23
+ replyTo: idSchema.optional()
24
+ });
25
+ //#endregion
26
+ //#region ../dto/src/responses.ts
27
+ const intLike = z.union([z.number(), z.string()]);
28
+ const intLikeOrNull = z.union([
29
+ z.number(),
30
+ z.string(),
31
+ z.null()
32
+ ]);
33
+ /** An exercise as the library + create endpoints return it (only the fields we read). */
34
+ const exerciseResponseSchema = z.looseObject({
35
+ id: intLike,
36
+ title: z.string(),
37
+ param_1_type: intLikeOrNull.optional(),
38
+ param_2_type: intLikeOrNull.optional()
39
+ });
40
+ /** The exercise library list (envelope already unwrapped). */
41
+ const exerciseLibraryResponseSchema = z.array(exerciseResponseSchema);
42
+ /** The create-session response (a programWorkout): we read workout_id + id. */
43
+ const sessionCreateResponseSchema = z.looseObject({
44
+ workout_id: intLike,
45
+ id: intLike
46
+ });
47
+ /** A programWorkout from the calendar edit view (we match by id and walk sets). */
48
+ const programWorkoutResponseSchema = z.looseObject({
49
+ id: intLike,
50
+ sets: z.record(z.string(), z.unknown()).optional()
51
+ });
52
+ /** The calendar edit-view response we read sessions back from. */
53
+ const programsEditResponseSchema = z.looseObject({ programWorkouts: z.array(programWorkoutResponseSchema).optional() });
54
+ //#endregion
55
+ //#region ../dto/src/workout.ts
56
+ /** A single exercise prescription inside a block. */
57
+ const exerciseSpecSchema = z.object({
58
+ id: z.union([z.number(), z.string()]),
59
+ title: z.string().optional(),
60
+ reps: z.union([
61
+ z.number(),
62
+ z.string(),
63
+ z.array(z.union([z.number(), z.string()]))
64
+ ]).optional(),
65
+ sets: z.number().optional(),
66
+ weight: z.union([z.number(), z.array(z.number())]).optional(),
67
+ rpe: z.union([z.number(), z.string()]).optional(),
68
+ instr: z.string().optional(),
69
+ param_1_type: z.number().optional(),
70
+ param_2_type: z.number().optional()
71
+ });
72
+ /** A block's Red-Zone leaderboard: a unit string/number, or an object with options. */
73
+ const leaderboardSpecSchema = z.union([
74
+ z.string(),
75
+ z.number(),
76
+ z.object({
77
+ unit: z.union([z.string(), z.number()]).optional(),
78
+ type: z.union([z.string(), z.number()]).optional(),
79
+ lowest_wins: z.boolean().optional(),
80
+ instruction: z.string().optional()
81
+ })
82
+ ]);
83
+ /** A block (group of exercises); two exercises render as a superset. */
84
+ const blockSpecSchema = z.object({
85
+ title: z.string(),
86
+ type: z.number().optional(),
87
+ instruction: z.string().optional(),
88
+ leaderboard: leaderboardSpecSchema.optional(),
89
+ exercises: z.array(exerciseSpecSchema)
90
+ });
91
+ /** A full session spec: the blocks plus an optional session note (Coach Instructions). */
92
+ const workoutSpecSchema = z.object({
93
+ blocks: z.array(blockSpecSchema),
94
+ instruction: z.string().optional()
95
+ });
96
+ /**
97
+ * Parse a `YYYY-M-D` string into the `WorkoutDate` tuple. The single home for this
98
+ * conversion, shared by the MCP tools and the CLI so they cannot drift on what counts
99
+ * as a valid date. Each part must be an integer.
100
+ */
101
+ function parseWorkoutDate(s) {
102
+ const parts = s.split("-").map((p) => Number(p));
103
+ if (parts.length !== 3 || parts.some((n) => !Number.isInteger(n))) throw new Error(`date must be YYYY-M-D, got "${s}".`);
104
+ return [
105
+ parts[0],
106
+ parts[1],
107
+ parts[2]
108
+ ];
109
+ }
110
+ //#endregion
111
+ //#region ../js/src/auth.ts
112
+ const AUTH_URL = "https://apis.trainheroic.com/auth";
113
+ /**
114
+ * Authenticate against TrainHeroic. Returns the session bundle, or null on bad
115
+ * credentials. TrainHeroic returns only { id, scope, role, session_id } (verified in
116
+ * the Phase 0 spike: no refresh_token, no api_token, no TTL). The 48-char session_id
117
+ * is sent as the `session-token` header and works against both API hosts.
118
+ */
119
+ async function loginTrainHeroic(email, password) {
120
+ const res = await fetch(AUTH_URL, {
121
+ method: "POST",
122
+ headers: {
123
+ "content-type": "application/x-www-form-urlencoded",
124
+ accept: "application/json"
125
+ },
126
+ body: new URLSearchParams({
127
+ email,
128
+ password
129
+ }).toString()
130
+ });
131
+ if (!res.ok) return null;
132
+ const data = await res.json().catch(() => null);
133
+ if (!data || typeof data.id !== "number" || !data.session_id) return null;
134
+ return {
135
+ thUserId: data.id,
136
+ sessionId: data.session_id,
137
+ scope: data.scope ?? "",
138
+ role: data.role ?? ""
139
+ };
140
+ }
141
+ //#endregion
142
+ //#region ../js/src/client.ts
143
+ const COACH_BASE = "https://api.trainheroic.com";
144
+ const APIS_BASE = "https://apis.trainheroic.com";
145
+ var TrainHeroicAuthError = class extends Error {
146
+ name = "TrainHeroicAuthError";
147
+ };
148
+ /**
149
+ * Authenticated TrainHeroic API client. Holds the coach credentials (from the grant's
150
+ * encrypted props) and a lazily-acquired session token cached in memory for the life
151
+ * of the Durable Object instance. On a 401/403 it re-logs in once and retries, since
152
+ * TrainHeroic has no refresh token and sessions expire after ~1-2h.
153
+ */
154
+ var TrainHeroicClient = class {
155
+ #email;
156
+ #password;
157
+ #sessionId;
158
+ #loginInFlight = null;
159
+ constructor(email, password, sessionId = null) {
160
+ this.#email = email;
161
+ this.#password = password;
162
+ this.#sessionId = sessionId;
163
+ }
164
+ get sessionId() {
165
+ return this.#sessionId;
166
+ }
167
+ async #ensureSession() {
168
+ if (this.#sessionId) return this.#sessionId;
169
+ this.#loginInFlight ??= this.#login();
170
+ try {
171
+ return await this.#loginInFlight;
172
+ } finally {
173
+ this.#loginInFlight = null;
174
+ }
175
+ }
176
+ async #login() {
177
+ const session = await loginTrainHeroic(this.#email, this.#password);
178
+ if (!session) throw new TrainHeroicAuthError("TrainHeroic login failed");
179
+ this.#sessionId = session.sessionId;
180
+ return this.#sessionId;
181
+ }
182
+ async request(method, path, options = {}) {
183
+ const url = `${options.base === "apis" ? APIS_BASE : COACH_BASE}/${path.replace(/^\//, "")}`;
184
+ let session = await this.#ensureSession();
185
+ let res = await this.#send(method, url, session, options.body);
186
+ if (res.status === 401 || res.status === 403) {
187
+ if (this.#sessionId === session) this.#sessionId = null;
188
+ session = await this.#ensureSession();
189
+ res = await this.#send(method, url, session, options.body);
190
+ }
191
+ const text = await res.text();
192
+ let data = text;
193
+ if (text.length > 0) try {
194
+ data = JSON.parse(text);
195
+ } catch {
196
+ data = text;
197
+ }
198
+ return {
199
+ status: res.status,
200
+ ok: res.ok,
201
+ data
202
+ };
203
+ }
204
+ #send(method, url, session, body) {
205
+ const upper = method.toUpperCase();
206
+ const headers = {
207
+ accept: "application/json",
208
+ "session-token": session
209
+ };
210
+ const init = {
211
+ method: upper,
212
+ headers
213
+ };
214
+ if (body !== void 0 && upper !== "GET" && upper !== "DELETE") {
215
+ headers["content-type"] = "application/json";
216
+ init.body = JSON.stringify(body);
217
+ }
218
+ return fetch(url, init);
219
+ }
220
+ };
221
+ //#endregion
222
+ //#region ../js/src/response-check.ts
223
+ /**
224
+ * Validate an API response against its (loose) expected shape and warn once on drift.
225
+ * Never throws: callers keep working via defensive coercion. This only surfaces a signal
226
+ * when TrainHeroic renames or drops a field we read.
227
+ */
228
+ function checkResponse(schema, data, label) {
229
+ const result = schema.safeParse(data);
230
+ if (result.success) return;
231
+ const issue = result.error.issues[0];
232
+ const where = issue && issue.path.length > 0 ? issue.path.join(".") : "(root)";
233
+ console.warn(`[trainheroic] response drift in ${label} at ${where}: ${issue?.message ?? "shape mismatch"}`);
234
+ }
235
+ //#endregion
236
+ //#region ../js/src/exercise-util.ts
237
+ /**
238
+ * Display labels for TrainHeroic parameter types. The unit is FIXED PER EXERCISE
239
+ * (the API forces param_1_type/param_2_type back to the library default on save), so
240
+ * resolve/search surface it to stop callers picking, say, the miles "Run" for a
241
+ * metric workout. Keep in sync with the workout builder's unit table.
242
+ */
243
+ const PARAM_UNIT = {
244
+ 0: null,
245
+ 1: "lb",
246
+ 2: "%max",
247
+ 3: "reps",
248
+ 4: "sec",
249
+ 5: "yd",
250
+ 6: "m",
251
+ 7: "in",
252
+ 10: "mi",
253
+ 11: "ft",
254
+ 12: "in",
255
+ 13: "bpm",
256
+ 14: "RPE",
257
+ 18: "sec"
258
+ };
259
+ function coerceInt(value) {
260
+ if (typeof value === "boolean") return value ? 1 : 0;
261
+ if (typeof value === "number") return Number.isFinite(value) ? Math.trunc(value) : null;
262
+ if (typeof value === "string" && value.trim() !== "") {
263
+ const n = Number(value);
264
+ return Number.isFinite(n) ? Math.trunc(n) : null;
265
+ }
266
+ return null;
267
+ }
268
+ function unitLabel(paramType) {
269
+ const t = coerceInt(paramType);
270
+ if (t === null) return null;
271
+ return PARAM_UNIT[t] ?? null;
272
+ }
273
+ /** Annotate a row with human-readable units for display. */
274
+ function withUnits(row) {
275
+ return {
276
+ ...row,
277
+ param_1_unit: unitLabel(row.param_1_type),
278
+ param_2_unit: unitLabel(row.param_2_type)
279
+ };
280
+ }
281
+ function buildSearchText(title) {
282
+ return title.trim().toLowerCase();
283
+ }
284
+ /** Strip the {"success":1,"data":X} envelope some 2.0/coach endpoints use. */
285
+ function unwrapEnvelope(body) {
286
+ if (body && typeof body === "object" && !Array.isArray(body)) {
287
+ const obj = body;
288
+ const keys = new Set(Object.keys(obj));
289
+ const envelope = /* @__PURE__ */ new Set([
290
+ "success",
291
+ "data",
292
+ "message",
293
+ "error"
294
+ ]);
295
+ if ("data" in obj && [...keys].every((k) => envelope.has(k))) return obj.data;
296
+ }
297
+ return body;
298
+ }
299
+ /** Pull the exercise array out of whatever shape the bulk endpoint returns. */
300
+ function asExerciseList(body) {
301
+ const unwrapped = unwrapEnvelope(body);
302
+ if (Array.isArray(unwrapped)) return unwrapped.filter((x) => isRecord(x));
303
+ if (isRecord(unwrapped)) {
304
+ const items = [];
305
+ for (const key of [
306
+ "exercises",
307
+ "circuits",
308
+ "workoutCircuits",
309
+ "library",
310
+ "items",
311
+ "results"
312
+ ]) {
313
+ const value = unwrapped[key];
314
+ if (Array.isArray(value)) items.push(...value.filter((x) => isRecord(x)));
315
+ }
316
+ if (items.length > 0) return items;
317
+ const values = Object.values(unwrapped);
318
+ if (values.length > 0 && values.every(isRecord)) return values;
319
+ }
320
+ return [];
321
+ }
322
+ function isRecord(x) {
323
+ return typeof x === "object" && x !== null && !Array.isArray(x);
324
+ }
325
+ /**
326
+ * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
327
+ * exact title, then prefix, then count of matched tokens, with shorter titles and
328
+ * standard (non-custom) exercises preferred on ties.
329
+ */
330
+ function rankSearch(rows, query, limit) {
331
+ const q = query.trim().toLowerCase();
332
+ const tokens = q.split(/\s+/u).filter((t) => t.length > 0);
333
+ return rows.map((row) => {
334
+ const title = row.title.toLowerCase();
335
+ let score = 0;
336
+ if (title === q) score += 1e3;
337
+ if (title.startsWith(q)) score += 100;
338
+ for (const tok of tokens) if (title.includes(tok)) score += 10;
339
+ score -= title.length * .05;
340
+ if (row.can_edit === 0) score += 1;
341
+ return {
342
+ row,
343
+ score
344
+ };
345
+ }).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
346
+ }
347
+ //#endregion
348
+ //#region ../js/src/workout-encode.ts
349
+ const LEADERBOARD_TYPE = {
350
+ completion: 0,
351
+ "for completion": 0,
352
+ weight: 1,
353
+ lb: 1,
354
+ load: 1,
355
+ reps: 2,
356
+ rep: 2,
357
+ rounds: 3,
358
+ round: 3,
359
+ time: 4,
360
+ yards: 5,
361
+ yd: 5,
362
+ meters: 6,
363
+ m: 6,
364
+ feet: 7,
365
+ ft: 7,
366
+ calories: 8,
367
+ cal: 8,
368
+ cals: 8,
369
+ miles: 10,
370
+ mi: 10,
371
+ inches: 12,
372
+ in: 12,
373
+ watts: 15,
374
+ w: 15,
375
+ velocity: 17,
376
+ "m/s": 17,
377
+ seconds: 18,
378
+ sec: 18,
379
+ s: 18
380
+ };
381
+ const LEADERBOARD_LABEL = {
382
+ 0: "For Completion",
383
+ 1: "Weight",
384
+ 2: "Reps",
385
+ 3: "Rounds",
386
+ 4: "Time",
387
+ 5: "Yards",
388
+ 6: "Meters",
389
+ 7: "Feet",
390
+ 8: "Calories",
391
+ 10: "Miles",
392
+ 12: "Inches",
393
+ 13: "Other",
394
+ 15: "Watts",
395
+ 16: "Percent",
396
+ 17: "Velocity",
397
+ 18: "Seconds"
398
+ };
399
+ function resolveLeaderboard(block) {
400
+ const lb = block.leaderboard;
401
+ if (lb === void 0 || lb === null) return {
402
+ isRedzone: null,
403
+ redzoneType: 0,
404
+ smallerIsBetter: null,
405
+ redzoneInstruction: ""
406
+ };
407
+ let unit;
408
+ let instruction = "";
409
+ let lowest;
410
+ if (typeof lb === "object") {
411
+ unit = lb.unit ?? lb.type;
412
+ instruction = lb.instruction ?? "";
413
+ lowest = lb.lowest_wins;
414
+ } else unit = lb;
415
+ let rz;
416
+ if (typeof unit === "string") {
417
+ const found = LEADERBOARD_TYPE[unit.trim().toLowerCase()];
418
+ if (found === void 0) throw new Error(`Unknown leaderboard unit '${unit}'. Use one of: ${Object.keys(LEADERBOARD_TYPE).join(", ")}.`);
419
+ rz = found;
420
+ } else if (typeof unit === "number") rz = Math.trunc(unit);
421
+ else throw new Error("Leaderboard requires a unit.");
422
+ if (lowest === void 0) lowest = rz === 4 || rz === 18;
423
+ return {
424
+ isRedzone: 1,
425
+ redzoneType: rz,
426
+ smallerIsBetter: lowest ? 1 : 0,
427
+ redzoneInstruction: instruction
428
+ };
429
+ }
430
+ function slots(values, n = 10) {
431
+ const out = [];
432
+ for (let i = 0; i < n; i += 1) out.push(values && i < values.length ? values[i] ?? "" : "");
433
+ return out;
434
+ }
435
+ function repsList(ex) {
436
+ const reps = ex.reps;
437
+ if (Array.isArray(reps)) return reps.map((r) => String(r));
438
+ if (reps === void 0 || reps === null) return [];
439
+ const sets = Math.max(1, Math.trunc(Number(ex.sets ?? 1)) || 1);
440
+ return Array.from({ length: sets }, () => String(reps));
441
+ }
442
+ /** Build one saveWorkoutSetExercises payload entry with all ten param slots filled. */
443
+ function makeExercise(ex, workoutSetId, order, key) {
444
+ const reps = repsList(ex);
445
+ let instruction = ex.instr ?? "";
446
+ if (instruction === "" && ex.rpe !== void 0 && ex.rpe !== null) instruction = `RPE ${ex.rpe}`;
447
+ const hasWeight = ex.weight !== void 0 && ex.weight !== null;
448
+ const weightArr = Array.isArray(ex.weight) ? ex.weight : null;
449
+ let count = reps.length;
450
+ if (count === 0 && hasWeight) count = weightArr ? weightArr.length : Math.max(1, Math.trunc(Number(ex.sets ?? 1)) || 1);
451
+ let param2Type;
452
+ let param2Values;
453
+ if (hasWeight) {
454
+ param2Type = ex.param_2_type ?? 1;
455
+ param2Values = (weightArr ?? Array.from({ length: count }, () => ex.weight)).map((v) => String(v));
456
+ } else {
457
+ param2Type = 0;
458
+ param2Values = null;
459
+ }
460
+ const entry = {
461
+ exercise_id: ex.id,
462
+ workout_set_id: workoutSetId,
463
+ set_id: workoutSetId,
464
+ setKey: workoutSetId,
465
+ title: ex.title ?? "",
466
+ instruction,
467
+ order,
468
+ param_1_type: ex.param_1_type ?? 3,
469
+ param_2_type: param2Type,
470
+ workout_set_exercise_template_id: null,
471
+ no_sets: 0,
472
+ param_count: count,
473
+ set_num: count,
474
+ key,
475
+ video_url: "",
476
+ thumbnail_url: "",
477
+ tags: [],
478
+ eType: "e",
479
+ use_count: 0
480
+ };
481
+ const p1 = slots(reps);
482
+ const p2 = slots(param2Values);
483
+ for (let i = 0; i < 10; i += 1) {
484
+ entry[`param_1_data_${i + 1}`] = p1[i] ?? "";
485
+ entry[`param_2_data_${i + 1}`] = p2[i] ?? "";
486
+ }
487
+ return entry;
488
+ }
489
+ function buildBlockPayload(blocks, workoutId) {
490
+ return blocks.map((b, i) => {
491
+ const lb = resolveLeaderboard(b);
492
+ return {
493
+ workout_id: workoutId,
494
+ order: i + 1,
495
+ type: b.type ?? 2,
496
+ instruction: b.instruction ?? "",
497
+ is_redzone: lb.isRedzone,
498
+ redzone_type: lb.redzoneType,
499
+ smaller_is_better: lb.smallerIsBetter,
500
+ redzone_instruction: lb.redzoneInstruction,
501
+ exercises: [],
502
+ exerciseKeys: [],
503
+ key: `k::${workoutId}${i + 1}`,
504
+ title: b.title
505
+ };
506
+ });
507
+ }
508
+ function unitOr(t) {
509
+ return unitLabel(t) ?? "?";
510
+ }
511
+ /** Flag spec params the API will silently override to the exercise's fixed units. */
512
+ function unitAdvisory(blockTitle, ex, defaults) {
513
+ const notes = [];
514
+ const warnings = [];
515
+ const u = unitOr;
516
+ const label = `${blockTitle} / ${ex.title ?? ex.id}`;
517
+ if (Array.isArray(ex.reps) && ex.sets !== void 0 && ex.reps.length !== ex.sets) warnings.push(`${label}: reps array has ${ex.reps.length} entr${ex.reps.length === 1 ? "y" : "ies"}; sets:${ex.sets} is ignored — building ${ex.reps.length} set(s).`);
518
+ const sentP1 = ex.param_1_type;
519
+ if (sentP1 !== void 0 && sentP1 !== null && Math.trunc(Number(sentP1)) !== defaults.param1) {
520
+ const sp1 = Math.trunc(Number(sentP1));
521
+ warnings.push(`${label}: param_1_type ${sentP1} (${u(sp1)}) is ignored — this exercise is fixed to ${u(defaults.param1)}; values render as ${u(defaults.param1)}.`);
522
+ } else if (defaults.param1 !== 3 && defaults.param1 !== null) notes.push(`${label}: values are in ${u(defaults.param1)} (the exercise's fixed primary unit).`);
523
+ if (ex.weight !== void 0 && ex.weight !== null) {
524
+ const sentP2 = Math.trunc(Number(ex.param_2_type ?? 1));
525
+ const effP2 = defaults.param2 === 0 || defaults.param2 === null ? 1 : defaults.param2;
526
+ if (sentP2 !== effP2) if (sentP2 === 2 || sentP2 === 14) warnings.push(`${label}: ${u(sentP2)} does not stick on this exercise — it renders as ${u(effP2)}. Put it in the exercise 'instr' text and leave load blank.`);
527
+ else warnings.push(`${label}: load renders as ${u(effP2)}, not ${u(sentP2)} (this exercise's secondary unit is fixed).`);
528
+ }
529
+ return {
530
+ notes,
531
+ warnings
532
+ };
533
+ }
534
+ /**
535
+ * Run unit advisories across a whole block list against an exercise index. Shared by the
536
+ * MCP workout_build tool and the CLI so both surface the same notes/warnings. Ensures the
537
+ * index is loaded first, otherwise `defaults` returns null on a cold index and every
538
+ * advisory is silently dropped.
539
+ */
540
+ async function collectAdvisories(blocks, index) {
541
+ await index.ensureFresh();
542
+ const pairs = blocks.flatMap((b) => b.exercises.map((ex) => ({
543
+ block: b,
544
+ ex
545
+ })));
546
+ const defaults = await Promise.all(pairs.map((p) => {
547
+ const id = Number(p.ex.id);
548
+ return Number.isFinite(id) ? index.defaults(id) : Promise.resolve(null);
549
+ }));
550
+ const notes = [];
551
+ const warnings = [];
552
+ pairs.forEach((p, i) => {
553
+ const def = defaults[i];
554
+ if (!def) return;
555
+ const advisory = unitAdvisory(p.block.title, p.ex, def);
556
+ notes.push(...advisory.notes);
557
+ warnings.push(...advisory.warnings);
558
+ });
559
+ return {
560
+ notes,
561
+ warnings
562
+ };
563
+ }
564
+ //#endregion
565
+ //#region ../js/src/workout-session.ts
566
+ async function req(client, method, path, body) {
567
+ const res = await client.request(method, path, body === void 0 ? void 0 : { body });
568
+ if (!res.ok) {
569
+ const detail = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
570
+ throw new Error(`${method} ${path} failed (HTTP ${res.status}): ${detail}`);
571
+ }
572
+ return res.data;
573
+ }
574
+ function createPath(opts) {
575
+ if (opts.timelineDay !== void 0) return `/2.0/coach/calendar/workout/createWorkoutForTimelineDay/${opts.programId}/${opts.timelineDay}/null`;
576
+ if (!opts.date) throw new Error("workout build requires either date or timelineDay");
577
+ const [y, m, d] = opts.date;
578
+ return `/2.0/coach/calendar/workout/createWorkoutForDay/${opts.programId}/${y}/${m}/${d}/0`;
579
+ }
580
+ async function buildSession(client, opts) {
581
+ const sess = await req(client, "POST", createPath(opts), {});
582
+ checkResponse(sessionCreateResponseSchema, sess, "session create");
583
+ const workoutId = Number(sess.workout_id);
584
+ const pwId = Number(sess.id);
585
+ const created = await req(client, "POST", "/2.0/coach/calendar/saveProgramWorkoutSets", buildBlockPayload(opts.blocks, workoutId));
586
+ const byOrder = new Map(created.map((b) => [b.order, b.id]));
587
+ let counter = 0;
588
+ const payloads = opts.blocks.map((block, i) => {
589
+ const wsid = byOrder.get(i + 1);
590
+ if (wsid === void 0) throw new Error(`No saved block for order ${i + 1}.`);
591
+ return block.exercises.map((ex, j) => {
592
+ counter += 1;
593
+ return makeExercise(ex, wsid, j + 1, `k::${workoutId}${String(counter).padStart(3, "0")}`);
594
+ });
595
+ });
596
+ await Promise.all(payloads.map((p) => req(client, "POST", "/2.0/coach/calendar/saveWorkoutSetExercises", p)));
597
+ if (opts.instruction !== void 0 && opts.instruction !== "") {
598
+ const blockIds = [...byOrder.entries()].sort((a, b) => a[0] - b[0]).map(([, id]) => id);
599
+ await setSessionInstruction(client, workoutId, sess, opts.instruction, blockIds);
600
+ }
601
+ if (opts.publish ?? false) await req(client, "POST", "/2.0/coach/calendar/programWorkout/publish", [pwId]);
602
+ return {
603
+ pwId,
604
+ workoutId
605
+ };
606
+ }
607
+ /**
608
+ * Set a session's Coach Instructions (the day-note at the top of a session). `pw` is the
609
+ * programWorkout object (the create-time response or a day's edit-GET entry). The PUT wants
610
+ * the whole object back with `instruction` set and `sets`/`setKeys` as a flat list of block
611
+ * ids. This does NOT change publish state: `published` is sent exactly as it is on `pw`.
612
+ */
613
+ async function setSessionInstruction(client, workoutId, pw, instruction, blockIds) {
614
+ const body = {
615
+ ...pw,
616
+ instruction,
617
+ sets: blockIds,
618
+ setKeys: blockIds
619
+ };
620
+ await req(client, "PUT", `/3.0/coach/workout/${workoutId}`, body);
621
+ }
622
+ async function removeSession(client, programId, pwId) {
623
+ await req(client, "POST", "/2.0/coach/calendar/removeProgramWorkout", {
624
+ programId,
625
+ pwId
626
+ });
627
+ }
628
+ async function publishSession(client, pwId) {
629
+ await req(client, "POST", "/2.0/coach/calendar/programWorkout/publish", [pwId]);
630
+ }
631
+ function str(value) {
632
+ return value === void 0 || value === null ? "" : String(value);
633
+ }
634
+ async function readSession(client, programId, date, pwId) {
635
+ const [y, m, d] = date;
636
+ const data = await req(client, "GET", `/1.0/coach/programs/edit/${programId}/${y}/${m}/${d}`);
637
+ checkResponse(programsEditResponseSchema, data, "programs edit");
638
+ const pw = (data.programWorkouts ?? []).find((p) => coerceInt(p.id) === pwId);
639
+ if (!pw) throw new Error(`programWorkout ${pwId} not found on ${y}-${m}-${d}.`);
640
+ const setsObj = pw.sets ?? {};
641
+ const blocks = Object.values(setsObj).sort((a, b) => Number(a.order) - Number(b.order)).map((b) => readBlock(b));
642
+ return {
643
+ pwId,
644
+ date: `${str(pw.year)}-${str(pw.month)}-${str(pw.day)}`,
645
+ published: pw.published,
646
+ instruction: str(pw.instruction),
647
+ blocks
648
+ };
649
+ }
650
+ function readBlock(b) {
651
+ const rz = coerceInt(b.redzone_type);
652
+ let leaderboard = null;
653
+ if (rz && rz > 0) leaderboard = `FOR ${(LEADERBOARD_LABEL[rz] ?? `type ${rz}`).toUpperCase()}${b.smaller_is_better ? " (lowest wins)" : ""}`;
654
+ const exercises = (Array.isArray(b.exercises) ? b.exercises : []).sort((a, e) => Number(a.order) - Number(e.order)).map((ex) => readExercise(ex));
655
+ return {
656
+ order: Number(b.order),
657
+ title: str(b.title),
658
+ leaderboard,
659
+ exercises
660
+ };
661
+ }
662
+ function readExercise(ex) {
663
+ const reps = [];
664
+ const load = [];
665
+ for (let i = 1; i <= 10; i += 1) {
666
+ const r = str(ex[`param_1_data_${i}`]);
667
+ if (r !== "") reps.push(r);
668
+ const w = str(ex[`param_2_data_${i}`]);
669
+ if (w !== "") load.push(w);
670
+ }
671
+ return {
672
+ order: Number(ex.order),
673
+ title: str(ex.title),
674
+ reps,
675
+ primaryUnit: unitLabel(coerceInt(ex.param_1_type)),
676
+ load,
677
+ loadUnit: unitLabel(coerceInt(ex.param_2_type)),
678
+ instruction: str(ex.instruction)
679
+ };
680
+ }
681
+ //#endregion
682
+ //#region ../js/src/messaging.ts
683
+ const BUCKETS = [
684
+ ["team", "teams"],
685
+ ["athlete", "athletes"],
686
+ ["program", "programs"],
687
+ ["coach", "coaches"]
688
+ ];
689
+ /** Live list of chat streams, flattened to (stream, kind) tuples. */
690
+ async function fetchStreams(client) {
691
+ const res = await client.request("GET", "/v5/messaging/streams");
692
+ if (!res.ok || !isRecord(res.data)) throw new Error(`GET /v5/messaging/streams failed (HTTP ${res.status}).`);
693
+ const out = [];
694
+ for (const [kind, key] of BUCKETS) {
695
+ const bucket = res.data[key];
696
+ if (Array.isArray(bucket)) {
697
+ for (const s of bucket) if (isRecord(s) && coerceInt(s.id) !== null) out.push({
698
+ stream: s,
699
+ kind
700
+ });
701
+ }
702
+ }
703
+ return out;
704
+ }
705
+ /**
706
+ * The exact chat comment body the web app sends. The non-obvious required field is
707
+ * `feed_id` (the stream id repeated in the body); omitting it returns 400.
708
+ */
709
+ function buildCommentPayload(streamId, text, replyTo = null) {
710
+ return {
711
+ type: 0,
712
+ content: text,
713
+ photo_url: "",
714
+ photoUrl: "",
715
+ access_level: 0,
716
+ parent_feed_item_id: replyTo,
717
+ feed_id: streamId
718
+ };
719
+ }
720
+ async function sendComment(client, streamId, text, replyTo = null) {
721
+ const res = await client.request("POST", `/v5/messaging/streams/${streamId}/comments`, { body: buildCommentPayload(streamId, text, replyTo) });
722
+ if (!res.ok || typeof res.data !== "object" || res.data === null || res.data.id === void 0) throw new Error(`Message send failed (HTTP ${res.status}).`);
723
+ return res.data;
724
+ }
725
+ async function deleteComment(client, streamId, commentId) {
726
+ const res = await client.request("DELETE", `/v5/messaging/streams/${streamId}/comments/${commentId}`);
727
+ if (!res.ok) throw new Error(`Message delete failed (HTTP ${res.status}).`);
728
+ return res.data;
729
+ }
730
+ async function readLive(client, streamId, limit) {
731
+ const res = await client.request("GET", `/v5/messaging/streams/${streamId}/comments?lastCommentId=`);
732
+ if (!res.ok || !Array.isArray(res.data)) throw new Error(`Message read failed (HTTP ${res.status}).`);
733
+ return limit !== void 0 && limit > 0 ? res.data.slice(-limit) : res.data;
734
+ }
735
+ //#endregion
736
+ //#region ../js/src/library-cache.ts
737
+ /** In-memory cache (no persistence). The default when none is supplied. */
738
+ var MemoryLibraryCache = class {
739
+ #snapshot = null;
740
+ async load() {
741
+ return this.#snapshot;
742
+ }
743
+ async save(snapshot) {
744
+ this.#snapshot = snapshot;
745
+ }
746
+ };
747
+ //#endregion
748
+ //#region ../js/src/exercise-index.ts
749
+ const LIBRARY_PATH = "/v5/exerciseLibrary/all";
750
+ const CREATE_PATH = "/2.0/coach/exercise/create";
751
+ const TTL_MS$1 = 168 * 3600 * 1e3;
752
+ function toStored(ex) {
753
+ const id = coerceInt(ex.id);
754
+ if (id === null) return null;
755
+ const title = String(ex.title ?? "");
756
+ return {
757
+ id,
758
+ title,
759
+ search: buildSearchText(title),
760
+ param_1_type: coerceInt(ex.param_1_type),
761
+ param_2_type: coerceInt(ex.param_2_type),
762
+ can_edit: coerceInt(ex.can_edit) ?? 0,
763
+ user_id: coerceInt(ex.user_id),
764
+ use_count: coerceInt(ex.use_count) ?? 0,
765
+ raw: ex
766
+ };
767
+ }
768
+ function toRow(s) {
769
+ return {
770
+ id: s.id,
771
+ title: s.title,
772
+ param_1_type: s.param_1_type,
773
+ param_2_type: s.param_2_type,
774
+ can_edit: s.can_edit,
775
+ user_id: s.user_id,
776
+ use_count: s.use_count
777
+ };
778
+ }
779
+ /**
780
+ * The exercise library held in memory for fast queries and persisted through a
781
+ * LibraryCache (JSON file for a CLI/local server, in-memory by default). Same
782
+ * resolve/search/unit behavior as the D1-backed store, with no database.
783
+ */
784
+ var ExerciseLibrary = class {
785
+ #client;
786
+ #cache;
787
+ #byId = /* @__PURE__ */ new Map();
788
+ #loaded = false;
789
+ #fetchedAt = 0;
790
+ constructor(client, cache = new MemoryLibraryCache()) {
791
+ this.#client = client;
792
+ this.#cache = cache;
793
+ }
794
+ #hydrate(list, fetchedAt) {
795
+ const next = /* @__PURE__ */ new Map();
796
+ for (const ex of list) {
797
+ const s = toStored(ex);
798
+ if (s) next.set(s.id, s);
799
+ }
800
+ this.#byId = next;
801
+ this.#fetchedAt = fetchedAt;
802
+ this.#loaded = true;
803
+ }
804
+ async #ensureLoaded() {
805
+ if (this.#loaded) return;
806
+ const snap = await this.#cache.load();
807
+ if (snap && snap.exercises.length > 0 && Date.now() - snap.fetchedAt <= TTL_MS$1) this.#hydrate(snap.exercises, snap.fetchedAt);
808
+ else await this.refresh();
809
+ }
810
+ async #persist() {
811
+ await this.#cache.save({
812
+ fetchedAt: this.#fetchedAt,
813
+ exercises: [...this.#byId.values()].map((s) => s.raw)
814
+ });
815
+ }
816
+ async ensureFresh() {
817
+ if (!this.#loaded) await this.#ensureLoaded();
818
+ else if (Date.now() - this.#fetchedAt > TTL_MS$1) await this.refresh();
819
+ }
820
+ async refresh() {
821
+ const res = await this.#client.request("GET", LIBRARY_PATH);
822
+ if (!res.ok) throw new Error(`Exercise library fetch failed (HTTP ${res.status}).`);
823
+ const list = asExerciseList(res.data);
824
+ if (list.length === 0) throw new Error("Exercise library returned no rows; keeping the cache.");
825
+ checkResponse(exerciseLibraryResponseSchema, list, "exercise library");
826
+ this.#hydrate(list, Date.now());
827
+ await this.#persist();
828
+ return { synced: this.#byId.size };
829
+ }
830
+ async get(id) {
831
+ await this.ensureFresh();
832
+ const s = this.#byId.get(id);
833
+ if (!s) return null;
834
+ const full = { ...s.raw };
835
+ full.param_1_unit = unitLabel(full.param_1_type);
836
+ full.param_2_unit = unitLabel(full.param_2_type);
837
+ return full;
838
+ }
839
+ async defaults(id) {
840
+ const s = this.#byId.get(id);
841
+ return s ? {
842
+ param1: s.param_1_type,
843
+ param2: s.param_2_type
844
+ } : null;
845
+ }
846
+ async search(query, limit = 20) {
847
+ await this.ensureFresh();
848
+ return this.#searchOnly(query, limit);
849
+ }
850
+ #searchOnly(query, limit) {
851
+ const tokens = query.toLowerCase().split(/\s+/u).filter((t) => t.length > 0);
852
+ if (tokens.length === 0) return [];
853
+ return rankSearch([...this.#byId.values()].filter((s) => tokens.every((t) => s.search.includes(t))).map((s) => toRow(s)), query, limit).map(withUnits);
854
+ }
855
+ #exact(name) {
856
+ const q = name.trim().toLowerCase();
857
+ const first = [...this.#byId.values()].filter((s) => s.search === q).sort((a, b) => a.can_edit - b.can_edit)[0];
858
+ return first ? withUnits(toRow(first)) : null;
859
+ }
860
+ async resolve(name) {
861
+ await this.ensureFresh();
862
+ let hit = this.#exact(name);
863
+ if (hit) return {
864
+ match: hit,
865
+ candidates: [hit]
866
+ };
867
+ let candidates = this.#searchOnly(name, 20);
868
+ if (candidates.length === 0) {
869
+ await this.refresh();
870
+ hit = this.#exact(name);
871
+ if (hit) return {
872
+ match: hit,
873
+ candidates: [hit]
874
+ };
875
+ candidates = this.#searchOnly(name, 20);
876
+ }
877
+ if (candidates.length === 1) return {
878
+ match: candidates[0] ?? null,
879
+ candidates
880
+ };
881
+ return {
882
+ match: null,
883
+ candidates
884
+ };
885
+ }
886
+ async create(body) {
887
+ await this.ensureFresh();
888
+ const res = await this.#client.request("POST", CREATE_PATH, { body });
889
+ if (!res.ok) throw new Error(`Exercise create failed (HTTP ${res.status}).`);
890
+ const ex = unwrapEnvelope(res.data);
891
+ if (ex && typeof ex === "object") {
892
+ checkResponse(exerciseResponseSchema, ex, "exercise create");
893
+ const s = toStored(ex);
894
+ if (s) {
895
+ this.#byId.set(s.id, s);
896
+ await this.#persist();
897
+ }
898
+ }
899
+ return ex;
900
+ }
901
+ async recordDelete(id) {
902
+ await this.ensureFresh();
903
+ if (this.#byId.delete(id)) await this.#persist();
904
+ }
905
+ async stats() {
906
+ let custom = 0;
907
+ for (const s of this.#byId.values()) if (s.can_edit === 1) custom += 1;
908
+ return {
909
+ exercises: this.#byId.size,
910
+ custom,
911
+ loaded: this.#loaded,
912
+ fetchedAt: this.#fetchedAt
913
+ };
914
+ }
915
+ };
916
+ //#endregion
917
+ //#region ../js/src/node.ts
918
+ /** Default exercise-cache path, overridable with TRAINHEROIC_CACHE_FILE. */
919
+ function defaultCachePath() {
920
+ return process.env.TRAINHEROIC_CACHE_FILE ?? join(homedir(), ".trainheroic", "library.json");
921
+ }
922
+ /** Persists the exercise library to a JSON file. */
923
+ var JsonFileLibraryCache = class {
924
+ #path;
925
+ constructor(path = defaultCachePath()) {
926
+ this.#path = path;
927
+ }
928
+ async load() {
929
+ try {
930
+ const parsed = JSON.parse(await readFile(this.#path, "utf8"));
931
+ if (typeof parsed.fetchedAt === "number" && Array.isArray(parsed.exercises)) return parsed;
932
+ } catch {}
933
+ return null;
934
+ }
935
+ async save(snapshot) {
936
+ await mkdir(dirname(this.#path), { recursive: true });
937
+ await writeFile(this.#path, JSON.stringify(snapshot), { mode: 384 });
938
+ }
939
+ };
940
+ //#endregion
941
+ //#region src/parse.ts
942
+ /** True when a string looks like inline JSON (starts with { or [) rather than a path. */
943
+ function looksLikeJson(s) {
944
+ return /^\s*[[{]/u.test(s);
945
+ }
946
+ //#endregion
947
+ //#region src/session-cache.ts
948
+ const TTL_MS = 6 * 3600 * 1e3;
949
+ function sessionPath() {
950
+ return process.env.TRAINHEROIC_SESSION_FILE ?? join(homedir(), ".trainheroic", "session.json");
951
+ }
952
+ async function loadSession() {
953
+ try {
954
+ const data = JSON.parse(await readFile(sessionPath(), "utf8"));
955
+ if (typeof data.sessionId === "string" && typeof data.savedAt === "number" && Date.now() - data.savedAt < TTL_MS) return data.sessionId;
956
+ } catch {}
957
+ return null;
958
+ }
959
+ async function saveSession(sessionId) {
960
+ if (!sessionId) return;
961
+ const p = sessionPath();
962
+ await mkdir(dirname(p), { recursive: true });
963
+ await writeFile(p, JSON.stringify({
964
+ sessionId,
965
+ savedAt: Date.now()
966
+ }), { mode: 384 });
967
+ }
968
+ //#endregion
969
+ //#region src/cli.ts
970
+ const HELP = `trainheroic — command-line tool for the TrainHeroic coaching API
971
+
972
+ Credentials come from TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD. Output is JSON.
973
+
974
+ Reads:
975
+ whoami | head-coach | athletes | programs | teams | notifications | analytics
976
+ program <id> | team <id> | team-codes <id>
977
+ request <METHOD> <path> [json] [--base coach|apis] [--file f] raw API call
978
+
979
+ Exercises (cached at ~/.trainheroic/library.json):
980
+ exercise resolve <name>
981
+ exercise search <query> [--limit N]
982
+ exercise get <id>
983
+ exercise sync [--force]
984
+ exercise create <json>|--file f
985
+ exercise forget <id> --yes
986
+ exercise stats
987
+
988
+ Workouts:
989
+ workout build --program <id> (--date Y-M-D | --timeline-day <n>) [--publish --yes] <spec.json>|--file f
990
+ workout read --program <id> --date Y-M-D --pw <id>
991
+ workout publish --pw <id> --yes
992
+ workout remove --program <id> --pw <id> --yes
993
+
994
+ Messaging:
995
+ message list
996
+ message read <streamId> [--limit N]
997
+ message draft <streamId> <text> [--reply-to <id>]
998
+ message send <streamId> <text> [--reply-to <id>] --yes
999
+ message delete <streamId> <commentId> --yes
1000
+ `;
1001
+ function out(value) {
1002
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
1003
+ }
1004
+ function fail(message) {
1005
+ process.stderr.write(`error: ${message}\n`);
1006
+ process.exit(1);
1007
+ }
1008
+ function need(value, usage) {
1009
+ if (value === void 0 || value === "") fail(`usage: trainheroic ${usage}`);
1010
+ return value;
1011
+ }
1012
+ function toInt(value, label) {
1013
+ const n = Number(value);
1014
+ if (!Number.isFinite(n)) fail(`${label} must be a number, got "${value}".`);
1015
+ return n;
1016
+ }
1017
+ /** Validate input against a dto schema, failing with a readable message on mismatch. */
1018
+ function validate(schema, value, label) {
1019
+ const result = schema.safeParse(value);
1020
+ if (!result.success) fail(`invalid ${label} — ${result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
1021
+ return result.data;
1022
+ }
1023
+ function parse(args, options) {
1024
+ try {
1025
+ return parseArgs({
1026
+ args,
1027
+ options,
1028
+ allowPositionals: true,
1029
+ strict: true
1030
+ });
1031
+ } catch (e) {
1032
+ return fail(e instanceof Error ? e.message : String(e));
1033
+ }
1034
+ }
1035
+ async function readStdin() {
1036
+ if (process.stdin.isTTY) return null;
1037
+ const chunks = [];
1038
+ for await (const c of process.stdin) chunks.push(c);
1039
+ const text = Buffer.concat(chunks).toString("utf8").trim();
1040
+ return text.length > 0 ? text : null;
1041
+ }
1042
+ /** JSON body from an inline arg, a --file path, or piped stdin. */
1043
+ async function jsonInput(inline, file) {
1044
+ let text = null;
1045
+ if (file !== void 0) text = await readFile(file, "utf8");
1046
+ else if (inline !== void 0) text = looksLikeJson(inline) ? inline : await readFile(inline, "utf8");
1047
+ else text = await readStdin();
1048
+ if (text === null) fail("expected JSON via an argument, --file, or stdin.");
1049
+ try {
1050
+ return JSON.parse(text);
1051
+ } catch {
1052
+ return fail("input is not valid JSON.");
1053
+ }
1054
+ }
1055
+ async function get(client, path, base) {
1056
+ const res = await client.request("GET", path, base ? { base } : {});
1057
+ if (!res.ok) fail(`GET ${path} failed (HTTP ${res.status}).`);
1058
+ return res.data;
1059
+ }
1060
+ function library(client) {
1061
+ return new ExerciseLibrary(client, new JsonFileLibraryCache());
1062
+ }
1063
+ async function cmdRequest(client, rest) {
1064
+ const { values, positionals } = parse(rest, {
1065
+ base: { type: "string" },
1066
+ file: { type: "string" }
1067
+ });
1068
+ const method = need(positionals[0], "request <METHOD> <path> [json]").toUpperCase();
1069
+ const path = need(positionals[1], "request <METHOD> <path> [json]");
1070
+ const base = values.base;
1071
+ if (base !== void 0 && base !== "coach" && base !== "apis") fail("--base must be coach or apis.");
1072
+ const opts = {};
1073
+ if (base !== void 0) opts.base = base;
1074
+ if (method !== "GET" && method !== "DELETE") {
1075
+ if (positionals[2] !== void 0 || values.file !== void 0 || !process.stdin.isTTY) opts.body = await jsonInput(positionals[2], values.file);
1076
+ }
1077
+ const res = await client.request(method, path, opts);
1078
+ out({
1079
+ status: res.status,
1080
+ ok: res.ok,
1081
+ data: res.data
1082
+ });
1083
+ }
1084
+ async function cmdExercise(client, rest) {
1085
+ const [sub, ...a] = rest;
1086
+ const lib = library(client);
1087
+ switch (sub) {
1088
+ case "resolve": return out(await lib.resolve(need(a.join(" ").trim() || void 0, "exercise resolve <name>")));
1089
+ case "search": {
1090
+ const { values, positionals } = parse(a, { limit: { type: "string" } });
1091
+ const query = need(positionals.join(" ").trim() || void 0, "exercise search <query>");
1092
+ const limit = values.limit !== void 0 ? toInt(values.limit, "--limit") : 20;
1093
+ return out(await lib.search(query, limit));
1094
+ }
1095
+ case "get": return out(await lib.get(toInt(need(a[0], "exercise get <id>"), "id")));
1096
+ case "sync": {
1097
+ const { values } = parse(a, { force: { type: "boolean" } });
1098
+ if (values.force === true) return out(await lib.refresh());
1099
+ await lib.ensureFresh();
1100
+ return out(await lib.stats());
1101
+ }
1102
+ case "create": {
1103
+ const { values, positionals } = parse(a, { file: { type: "string" } });
1104
+ const exercise = validate(exerciseCreateSchema, await jsonInput(positionals[0], values.file), "exercise");
1105
+ return out(await lib.create(exercise));
1106
+ }
1107
+ case "forget": {
1108
+ const { values, positionals } = parse(a, { yes: { type: "boolean" } });
1109
+ const id = toInt(need(positionals[0], "exercise forget <id> --yes"), "id");
1110
+ if (values.yes !== true) fail(`add --yes to forget exercise ${id} from the local cache.`);
1111
+ await lib.recordDelete(id);
1112
+ return out({ forgotten: id });
1113
+ }
1114
+ case "stats": return out(await lib.stats());
1115
+ default: return fail("usage: trainheroic exercise <resolve|search|get|sync|create|forget|stats>");
1116
+ }
1117
+ }
1118
+ async function cmdWorkout(client, rest) {
1119
+ const [sub, ...a] = rest;
1120
+ switch (sub) {
1121
+ case "build": {
1122
+ const { values, positionals } = parse(a, {
1123
+ program: { type: "string" },
1124
+ date: { type: "string" },
1125
+ "timeline-day": { type: "string" },
1126
+ publish: { type: "boolean" },
1127
+ yes: { type: "boolean" },
1128
+ file: { type: "string" }
1129
+ });
1130
+ const programId = toInt(need(values.program, "workout build --program <id> ..."), "--program");
1131
+ if (values.date === void 0 && values["timeline-day"] === void 0) fail("provide --date YYYY-M-D or --timeline-day <n>.");
1132
+ const parsed = await jsonInput(positionals[0], values.file);
1133
+ const spec = validate(workoutSpecSchema, Array.isArray(parsed) ? { blocks: parsed } : parsed, "workout spec");
1134
+ const publish = values.publish === true;
1135
+ if (publish && values.yes !== true) fail("publishing is athlete-facing; add --yes to build and publish.");
1136
+ const opts = {
1137
+ programId,
1138
+ blocks: spec.blocks,
1139
+ publish
1140
+ };
1141
+ if (values.date !== void 0) opts.date = parseWorkoutDate(values.date);
1142
+ if (values["timeline-day"] !== void 0) opts.timelineDay = toInt(values["timeline-day"], "--timeline-day");
1143
+ if (spec.instruction !== void 0) opts.instruction = spec.instruction;
1144
+ const advice = await collectAdvisories(spec.blocks, library(client));
1145
+ const built = await buildSession(client, opts);
1146
+ const readback = opts.date ? await readSession(client, programId, opts.date, built.pwId) : null;
1147
+ return out({
1148
+ ...built,
1149
+ published: publish,
1150
+ advisories: advice,
1151
+ readback
1152
+ });
1153
+ }
1154
+ case "read": {
1155
+ const { values } = parse(a, {
1156
+ program: { type: "string" },
1157
+ date: { type: "string" },
1158
+ pw: { type: "string" }
1159
+ });
1160
+ return out(await readSession(client, toInt(need(values.program, "workout read --program <id> --date Y-M-D --pw <id>"), "--program"), parseWorkoutDate(need(values.date, "workout read --program <id> --date Y-M-D --pw <id>")), toInt(need(values.pw, "workout read --program <id> --date Y-M-D --pw <id>"), "--pw")));
1161
+ }
1162
+ case "publish": {
1163
+ const { values } = parse(a, {
1164
+ pw: { type: "string" },
1165
+ yes: { type: "boolean" }
1166
+ });
1167
+ const pw = toInt(need(values.pw, "workout publish --pw <id> --yes"), "--pw");
1168
+ if (values.yes !== true) fail(`publishing makes pw ${pw} athlete-visible; add --yes.`);
1169
+ await publishSession(client, pw);
1170
+ return out({ published: pw });
1171
+ }
1172
+ case "remove": {
1173
+ const { values } = parse(a, {
1174
+ program: { type: "string" },
1175
+ pw: { type: "string" },
1176
+ yes: { type: "boolean" }
1177
+ });
1178
+ const programId = toInt(need(values.program, "workout remove --program <id> --pw <id> --yes"), "--program");
1179
+ const pw = toInt(need(values.pw, "workout remove --program <id> --pw <id> --yes"), "--pw");
1180
+ if (values.yes !== true) fail(`removing pw ${pw} deletes the session; add --yes.`);
1181
+ await removeSession(client, programId, pw);
1182
+ return out({ removed: pw });
1183
+ }
1184
+ default: return fail("usage: trainheroic workout <build|read|publish|remove>");
1185
+ }
1186
+ }
1187
+ async function cmdMessage(client, rest) {
1188
+ const [sub, ...a] = rest;
1189
+ switch (sub) {
1190
+ case "list": return out((await fetchStreams(client)).map(({ stream, kind }) => ({
1191
+ id: stream.id,
1192
+ kind,
1193
+ title: stream.title ?? "",
1194
+ teamId: stream.teamId,
1195
+ userId: stream.userId
1196
+ })));
1197
+ case "read": {
1198
+ const { values, positionals } = parse(a, { limit: { type: "string" } });
1199
+ return out(await readLive(client, toInt(need(positionals[0], "message read <streamId> [--limit N]"), "streamId"), values.limit !== void 0 ? toInt(values.limit, "--limit") : 20));
1200
+ }
1201
+ case "draft": {
1202
+ const { values, positionals } = parse(a, { "reply-to": { type: "string" } });
1203
+ return out({
1204
+ draft: true,
1205
+ note: "NOT sent. Run 'message send' with --yes to deliver.",
1206
+ payload: buildCommentPayload(toInt(need(positionals[0], "message draft <streamId> <text>"), "streamId"), need(positionals.slice(1).join(" ").trim() || void 0, "message draft <streamId> <text>"), values["reply-to"] !== void 0 ? toInt(values["reply-to"], "--reply-to") : null)
1207
+ });
1208
+ }
1209
+ case "send": {
1210
+ const { values, positionals } = parse(a, {
1211
+ "reply-to": { type: "string" },
1212
+ yes: { type: "boolean" }
1213
+ });
1214
+ const streamId = toInt(need(positionals[0], "message send <streamId> <text> --yes"), "streamId");
1215
+ const text = need(positionals.slice(1).join(" ").trim() || void 0, "message send <streamId> <text> --yes");
1216
+ const replyTo = values["reply-to"] !== void 0 ? toInt(values["reply-to"], "--reply-to") : null;
1217
+ if (values.yes !== true) fail(`sending to stream ${streamId} is athlete-facing and immediate; add --yes.`);
1218
+ return out({
1219
+ sent: true,
1220
+ comment: await sendComment(client, streamId, text, replyTo)
1221
+ });
1222
+ }
1223
+ case "delete": {
1224
+ const { values, positionals } = parse(a, { yes: { type: "boolean" } });
1225
+ const streamId = toInt(need(positionals[0], "message delete <streamId> <commentId> --yes"), "streamId");
1226
+ const commentId = toInt(need(positionals[1], "message delete <streamId> <commentId> --yes"), "commentId");
1227
+ if (values.yes !== true) fail(`deleting comment ${commentId} acts on the live account; add --yes.`);
1228
+ return out({
1229
+ deleted: true,
1230
+ response: await deleteComment(client, streamId, commentId)
1231
+ });
1232
+ }
1233
+ default: return fail("usage: trainheroic message <list|read|draft|send|delete>");
1234
+ }
1235
+ }
1236
+ async function dispatch(client, group, rest) {
1237
+ switch (group) {
1238
+ case "whoami": return out(await get(client, "/user/simple"));
1239
+ case "head-coach": return out(await get(client, "/v5/headCoach"));
1240
+ case "athletes": return out(await get(client, "/v5/athletes"));
1241
+ case "programs": return out(await get(client, "/1.0/coach/programs"));
1242
+ case "teams": return out(await get(client, "/1.0/coach/teams"));
1243
+ case "notifications": return out(await get(client, "/v5/notifications/counts"));
1244
+ case "analytics": return out(await get(client, "/v5/analytics"));
1245
+ case "program": return out(await get(client, `/3.0/coach/program/${encodeURIComponent(need(rest[0], "program <id>"))}`));
1246
+ case "team": return out(await get(client, `/v5/teams/${encodeURIComponent(need(rest[0], "team <id>"))}`));
1247
+ case "team-codes": return out(await get(client, `/v5/teams/${encodeURIComponent(need(rest[0], "team-codes <id>"))}/teamCodes`));
1248
+ case "request": return cmdRequest(client, rest);
1249
+ case "exercise": return cmdExercise(client, rest);
1250
+ case "workout": return cmdWorkout(client, rest);
1251
+ case "message": return cmdMessage(client, rest);
1252
+ default: return fail(`unknown command "${group}". Run 'trainheroic help'.`);
1253
+ }
1254
+ }
1255
+ async function main() {
1256
+ const [group, ...rest] = process.argv.slice(2);
1257
+ if (group === void 0 || group === "help" || group === "--help" || group === "-h") {
1258
+ process.stdout.write(HELP);
1259
+ return;
1260
+ }
1261
+ const email = process.env.TRAINHEROIC_EMAIL;
1262
+ const password = process.env.TRAINHEROIC_PASSWORD;
1263
+ if (!email || !password) fail("set TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD in the environment.");
1264
+ const client = new TrainHeroicClient(email, password, await loadSession());
1265
+ try {
1266
+ await dispatch(client, group, rest);
1267
+ } catch (e) {
1268
+ process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`);
1269
+ process.exitCode = 1;
1270
+ } finally {
1271
+ await saveSession(client.sessionId);
1272
+ }
1273
+ }
1274
+ await main();
1275
+ //#endregion
1276
+ export {};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@trainheroic-unofficial/cli",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/alandotcom/trainheroic-skill.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "description": "Command-line tool for the TrainHeroic coaching API.",
11
+ "type": "module",
12
+ "bin": {
13
+ "trainheroic": "./dist/cli.mjs"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "dependencies": {
22
+ "zod": "^4.4.3",
23
+ "@trainheroic-unofficial/dto": "0.1.0",
24
+ "@trainheroic-unofficial/js": "0.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^26.0.0",
28
+ "tsdown": "^0.22.3",
29
+ "tsx": "^4.22.4",
30
+ "typescript": "^6.0.3",
31
+ "vitest": "^4.1.9"
32
+ },
33
+ "scripts": {
34
+ "start": "tsx src/cli.ts",
35
+ "build": "tsdown",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run"
38
+ }
39
+ }