@trainheroic-unofficial/coach-mcp 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.
@@ -0,0 +1,1554 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import process from "node:process";
4
+ import { z } from "zod";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
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
+ /**
12
+ * An id as a tool argument: a number, or a string of digits. Unlike idSchema (which
13
+ * tolerates whatever the API sends back), this rejects non-numeric strings up front so
14
+ * a bad argument fails validation instead of becoming NaN in a URL or a query.
15
+ */
16
+ const idArgSchema = z.union([z.number().int(), z.string().regex(/^\d+$/u)]);
17
+ //#endregion
18
+ //#region ../dto/src/exercise.ts
19
+ /** Body for creating a custom exercise; extra fields the API accepts are preserved. */
20
+ const exerciseCreateSchema = z.looseObject({
21
+ title: z.string().min(1),
22
+ param_1_type: z.number().optional(),
23
+ param_2_type: z.number().optional()
24
+ });
25
+ //#endregion
26
+ //#region ../dto/src/messaging.ts
27
+ /** A chat comment to draft or send: target stream, body text, optional threaded reply. */
28
+ const commentDraftSchema = z.object({
29
+ streamId: idSchema,
30
+ text: z.string().min(1),
31
+ replyTo: idSchema.optional()
32
+ });
33
+ //#endregion
34
+ //#region ../dto/src/responses.ts
35
+ const intLike = z.union([z.number(), z.string()]);
36
+ const intLikeOrNull = z.union([
37
+ z.number(),
38
+ z.string(),
39
+ z.null()
40
+ ]);
41
+ /** An exercise as the library + create endpoints return it (only the fields we read). */
42
+ const exerciseResponseSchema = z.looseObject({
43
+ id: intLike,
44
+ title: z.string(),
45
+ param_1_type: intLikeOrNull.optional(),
46
+ param_2_type: intLikeOrNull.optional()
47
+ });
48
+ /** The exercise library list (envelope already unwrapped). */
49
+ const exerciseLibraryResponseSchema = z.array(exerciseResponseSchema);
50
+ /** The create-session response (a programWorkout): we read workout_id + id. */
51
+ const sessionCreateResponseSchema = z.looseObject({
52
+ workout_id: intLike,
53
+ id: intLike
54
+ });
55
+ /** A programWorkout from the calendar edit view (we match by id and walk sets). */
56
+ const programWorkoutResponseSchema = z.looseObject({
57
+ id: intLike,
58
+ sets: z.record(z.string(), z.unknown()).optional()
59
+ });
60
+ /** The calendar edit-view response we read sessions back from. */
61
+ const programsEditResponseSchema = z.looseObject({ programWorkouts: z.array(programWorkoutResponseSchema).optional() });
62
+ //#endregion
63
+ //#region ../dto/src/workout.ts
64
+ /** A single exercise prescription inside a block. */
65
+ const exerciseSpecSchema = z.object({
66
+ id: z.union([z.number(), z.string()]),
67
+ title: z.string().optional(),
68
+ reps: z.union([
69
+ z.number(),
70
+ z.string(),
71
+ z.array(z.union([z.number(), z.string()]))
72
+ ]).optional(),
73
+ sets: z.number().optional(),
74
+ weight: z.union([z.number(), z.array(z.number())]).optional(),
75
+ rpe: z.union([z.number(), z.string()]).optional(),
76
+ instr: z.string().optional(),
77
+ param_1_type: z.number().optional(),
78
+ param_2_type: z.number().optional()
79
+ });
80
+ /** A block's Red-Zone leaderboard: a unit string/number, or an object with options. */
81
+ const leaderboardSpecSchema = z.union([
82
+ z.string(),
83
+ z.number(),
84
+ z.object({
85
+ unit: z.union([z.string(), z.number()]).optional(),
86
+ type: z.union([z.string(), z.number()]).optional(),
87
+ lowest_wins: z.boolean().optional(),
88
+ instruction: z.string().optional()
89
+ })
90
+ ]);
91
+ /** A block (group of exercises); two exercises render as a superset. */
92
+ const blockSpecSchema = z.object({
93
+ title: z.string(),
94
+ type: z.number().optional(),
95
+ instruction: z.string().optional(),
96
+ leaderboard: leaderboardSpecSchema.optional(),
97
+ exercises: z.array(exerciseSpecSchema)
98
+ });
99
+ z.object({
100
+ blocks: z.array(blockSpecSchema),
101
+ instruction: z.string().optional()
102
+ });
103
+ /**
104
+ * Parse a `YYYY-M-D` string into the `WorkoutDate` tuple. The single home for this
105
+ * conversion, shared by the MCP tools and the CLI so they cannot drift on what counts
106
+ * as a valid date. Each part must be an integer.
107
+ */
108
+ function parseWorkoutDate(s) {
109
+ const parts = s.split("-").map((p) => Number(p));
110
+ if (parts.length !== 3 || parts.some((n) => !Number.isInteger(n))) throw new Error(`date must be YYYY-M-D, got "${s}".`);
111
+ return [
112
+ parts[0],
113
+ parts[1],
114
+ parts[2]
115
+ ];
116
+ }
117
+ //#endregion
118
+ //#region ../core/src/context.ts
119
+ /** A tool argument that accepts a numeric id as a number or a string of digits. */
120
+ const idParam = idArgSchema;
121
+ function toId(value) {
122
+ return typeof value === "number" ? value : Number(value);
123
+ }
124
+ const READ = {
125
+ readOnlyHint: true,
126
+ openWorldHint: true
127
+ };
128
+ const SYNC = {
129
+ readOnlyHint: false,
130
+ idempotentHint: true,
131
+ destructiveHint: false,
132
+ openWorldHint: true
133
+ };
134
+ const DESTRUCTIVE = {
135
+ readOnlyHint: false,
136
+ destructiveHint: true,
137
+ openWorldHint: true
138
+ };
139
+ /** Run a tool body, converting thrown errors into an in-band tool error. */
140
+ async function attempt(fn) {
141
+ try {
142
+ return await fn();
143
+ } catch (err) {
144
+ return errorResult(err instanceof Error ? err.message : String(err));
145
+ }
146
+ }
147
+ /** Conservative per-result character cap, below the smallest host cap. */
148
+ const DEFAULT_RESULT_BUDGET = 6e4;
149
+ /** Reserve for the `__truncated` marker so wrapping cannot push back over budget. */
150
+ const MARKER_RESERVE = 300;
151
+ const DEFAULT_ARRAY_HINT = "Result was truncated to fit the size budget. Narrow it with a filter/search argument or paginate to see the rest.";
152
+ const DEFAULT_OBJECT_HINT = "Result was truncated to fit the size budget. Request a more specific id or sub-resource.";
153
+ /** Active budget. Overridable via TH_MCP_RESULT_BUDGET on Node; the default on workerd. */
154
+ function resultBudget() {
155
+ const raw = (globalThis.process?.env)?.TH_MCP_RESULT_BUDGET;
156
+ const n = raw ? Number(raw) : NaN;
157
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_RESULT_BUDGET;
158
+ }
159
+ function isPlainObject(value) {
160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
161
+ }
162
+ /** Largest count k such that the JSON of the first k pre-serialized pieces fits. O(n). */
163
+ function largestPrefixCount(pieces, charBudget) {
164
+ let used = 2;
165
+ let k = 0;
166
+ for (const piece of pieces) {
167
+ const add = piece.length + (k > 0 ? 1 : 0);
168
+ if (used + add > charBudget) break;
169
+ used += add;
170
+ k += 1;
171
+ }
172
+ return k;
173
+ }
174
+ /** Last resort: cap a string at the budget and label it as truncated, non-JSON output. */
175
+ function hardCap(text, budget, hint) {
176
+ if (text.length <= budget) return text;
177
+ const note = `\n\n[TRUNCATED: output exceeded ${budget} chars and is NOT valid JSON. ${hint ?? "Narrow the query (filter, paginate, or fetch a specific id)."}]`;
178
+ const keep = Math.max(0, budget - note.length);
179
+ return text.slice(0, keep) + note;
180
+ }
181
+ function largestArrayValuedKey(obj) {
182
+ let best = null;
183
+ let bestLen = -1;
184
+ for (const [key, value] of Object.entries(obj)) {
185
+ if (!Array.isArray(value)) continue;
186
+ const len = (JSON.stringify(value) ?? "[]").length;
187
+ if (len > bestLen) {
188
+ best = key;
189
+ bestLen = len;
190
+ }
191
+ }
192
+ return best;
193
+ }
194
+ /**
195
+ * Serialize `data` as JSON within `budget` characters. Small results are pretty-printed.
196
+ * Oversized results degrade in order: trim a top-level array (wrapping it as
197
+ * `{ items, __truncated }`), then a top-level object's largest array property (annotated
198
+ * with `__truncated`), then a last-resort hard character cap. Pure and side-effect free.
199
+ */
200
+ function boundedSerialize(data, budget, hint) {
201
+ if (typeof data === "string") return hardCap(data, budget, hint);
202
+ const compact = JSON.stringify(data) ?? "null";
203
+ if (compact.length <= budget) {
204
+ const pretty = JSON.stringify(data, null, 2) ?? "null";
205
+ return pretty.length <= budget ? pretty : compact;
206
+ }
207
+ if (Array.isArray(data)) {
208
+ const k = largestPrefixCount(data.map((el) => JSON.stringify(el) ?? "null"), budget - MARKER_RESERVE);
209
+ const wrapped = {
210
+ items: data.slice(0, k),
211
+ __truncated: {
212
+ returned: k,
213
+ total: data.length,
214
+ omitted: data.length - k,
215
+ hint: hint ?? DEFAULT_ARRAY_HINT
216
+ }
217
+ };
218
+ const out = JSON.stringify(wrapped);
219
+ if (out.length <= budget) return out;
220
+ } else if (isPlainObject(data)) {
221
+ const key = largestArrayValuedKey(data);
222
+ if (key !== null) {
223
+ const arr = data[key];
224
+ const pieces = arr.map((el) => JSON.stringify(el) ?? "null");
225
+ const restLen = (JSON.stringify({
226
+ ...data,
227
+ [key]: []
228
+ }) ?? "{}").length;
229
+ const k = largestPrefixCount(pieces, Math.max(0, budget - MARKER_RESERVE - restLen));
230
+ const clone = {
231
+ ...data,
232
+ [key]: arr.slice(0, k),
233
+ __truncated: {
234
+ field: key,
235
+ returned: k,
236
+ total: arr.length,
237
+ omitted: arr.length - k,
238
+ hint: hint ?? DEFAULT_OBJECT_HINT
239
+ }
240
+ };
241
+ const out = JSON.stringify(clone);
242
+ if (out.length <= budget) return out;
243
+ }
244
+ }
245
+ return hardCap(compact, budget, hint);
246
+ }
247
+ /** A successful tool result carrying JSON (or text) for the model, size-bounded. */
248
+ function jsonResult(data, opts) {
249
+ return { content: [{
250
+ type: "text",
251
+ text: boundedSerialize(data, resultBudget(), opts?.hint)
252
+ }] };
253
+ }
254
+ /** A tool-level error: returned in-band (isError) so the model can self-correct. */
255
+ function errorResult(message) {
256
+ return {
257
+ isError: true,
258
+ content: [{
259
+ type: "text",
260
+ text: message
261
+ }]
262
+ };
263
+ }
264
+ /** Issue a TrainHeroic request and format the outcome as a tool result. */
265
+ async function apiCall(ctx, method, path, options, hint) {
266
+ return attempt(async () => {
267
+ const res = await ctx.client.request(method, path, options);
268
+ if (!res.ok) {
269
+ const detail = hardCap(typeof res.data === "string" ? res.data : JSON.stringify(res.data) ?? "", resultBudget());
270
+ return errorResult(`TrainHeroic API error (HTTP ${res.status}): ${detail}`);
271
+ }
272
+ return jsonResult(res.data, { hint });
273
+ });
274
+ }
275
+ //#endregion
276
+ //#region ../core/src/confirm.ts
277
+ const NOT_CONFIRMED = "Not confirmed. Re-run with confirm:true, or connect a client that supports MCP elicitation.";
278
+ /**
279
+ * Confirm a destructive/athlete-facing action. Prefers MCP elicitation (an
280
+ * in-the-moment user prompt); falls back to an explicit confirm:true argument when
281
+ * the client does not support elicitation. Never proceeds without one of the two.
282
+ */
283
+ async function confirmGate(server, requestId, message, confirmArg) {
284
+ if (confirmArg === true) return true;
285
+ try {
286
+ const result = await server.server.elicitInput({
287
+ message,
288
+ requestedSchema: {
289
+ type: "object",
290
+ properties: { confirm: {
291
+ type: "boolean",
292
+ title: "Confirm",
293
+ description: message
294
+ } },
295
+ required: ["confirm"]
296
+ }
297
+ }, requestId === void 0 ? void 0 : { relatedRequestId: requestId });
298
+ return result.action === "accept" && result.content?.confirm === true;
299
+ } catch (err) {
300
+ console.warn("MCP elicitation unavailable; treating as not confirmed", err);
301
+ return false;
302
+ }
303
+ }
304
+ //#endregion
305
+ //#region ../core/src/tools/reads.ts
306
+ function enc(value) {
307
+ return encodeURIComponent(String(value));
308
+ }
309
+ /** No-argument GET endpoints, registered from a table to keep the wiring compact. */
310
+ const SIMPLE_GETS = [
311
+ {
312
+ name: "whoami",
313
+ title: "Who am I",
314
+ description: "The authenticated TrainHeroic coach profile (id, org_id, name, roles, trial days).",
315
+ path: "/user/simple"
316
+ },
317
+ {
318
+ name: "head_coach",
319
+ title: "Head coach / org",
320
+ description: "Org, license, and trial status for the head coach account.",
321
+ path: "/v5/headCoach"
322
+ },
323
+ {
324
+ name: "list_programs",
325
+ title: "List programs",
326
+ description: "Coach programs (standalone). Team group-programs come from list_teams.",
327
+ path: "/1.0/coach/programs"
328
+ },
329
+ {
330
+ name: "notifications",
331
+ title: "Notification counts",
332
+ description: "Unread counts including countMessagingNotViewed (cheap 'anything new?' poll).",
333
+ path: "/v5/notifications/counts"
334
+ },
335
+ {
336
+ name: "analytics_categories",
337
+ title: "Analytics categories",
338
+ description: "Lists available analytics types. Pull the data via th_request POST /v5/analytics/*.",
339
+ path: "/v5/analytics"
340
+ }
341
+ ];
342
+ /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
343
+ function registerReadTools(server, ctx) {
344
+ for (const t of SIMPLE_GETS) server.registerTool(t.name, {
345
+ title: t.title,
346
+ description: t.description,
347
+ inputSchema: {},
348
+ annotations: READ
349
+ }, () => apiCall(ctx, "GET", t.path));
350
+ server.registerTool("list_athletes", {
351
+ title: "List athletes",
352
+ description: "Athletes visible to this coach. Optional q (case-insensitive substring filter over each athlete record) and limit, applied client-side, to keep large rosters small.",
353
+ inputSchema: {
354
+ q: z.string().optional(),
355
+ limit: z.number().int().positive().max(500).optional()
356
+ },
357
+ annotations: READ
358
+ }, ({ q, limit }) => attempt(async () => {
359
+ const res = await ctx.client.request("GET", "/v5/athletes");
360
+ if (!res.ok) {
361
+ const detail = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
362
+ throw new Error(`TrainHeroic API error (HTTP ${res.status}): ${detail}`);
363
+ }
364
+ if (!Array.isArray(res.data)) return jsonResult(res.data);
365
+ let rows = res.data;
366
+ if (q !== void 0) {
367
+ const needle = q.toLowerCase();
368
+ rows = rows.filter((row) => JSON.stringify(row).toLowerCase().includes(needle));
369
+ }
370
+ const total = rows.length;
371
+ if (limit !== void 0 && rows.length > limit) return jsonResult({
372
+ items: rows.slice(0, limit),
373
+ returned: limit,
374
+ total,
375
+ note: "Limited client-side. Raise limit or narrow with q for the rest."
376
+ });
377
+ return jsonResult(rows, { hint: "Filter with q (name/email substring) or cap with limit to shrink this list." });
378
+ }));
379
+ server.registerTool("list_teams", {
380
+ title: "List teams",
381
+ description: "Coach teams. Optional pagination and search.",
382
+ inputSchema: {
383
+ page: z.number().int().positive().optional(),
384
+ pageSize: z.number().int().positive().optional(),
385
+ q: z.string().optional()
386
+ },
387
+ annotations: READ
388
+ }, ({ page, pageSize, q }) => {
389
+ const qs = new URLSearchParams();
390
+ if (page !== void 0) qs.set("page", String(page));
391
+ if (pageSize !== void 0) qs.set("pageSize", String(pageSize));
392
+ if (q !== void 0) qs.set("q", q);
393
+ const query = qs.toString();
394
+ return apiCall(ctx, "GET", `/1.0/coach/teams${query ? `?${query}` : ""}`);
395
+ });
396
+ server.registerTool("get_team", {
397
+ title: "Get team",
398
+ description: "Full team object by team id.",
399
+ inputSchema: { teamId: idParam },
400
+ annotations: READ
401
+ }, ({ teamId }) => apiCall(ctx, "GET", `/v5/teams/${enc(teamId)}`));
402
+ server.registerTool("list_team_codes", {
403
+ title: "List team access codes",
404
+ description: "Join/access codes for a team.",
405
+ inputSchema: { teamId: idParam },
406
+ annotations: READ
407
+ }, ({ teamId }) => apiCall(ctx, "GET", `/v5/teams/${enc(teamId)}/teamCodes`));
408
+ server.registerTool("get_program", {
409
+ title: "Get program detail",
410
+ description: "Full nested program structure (blocks + sessions) live from the API, by program id.",
411
+ inputSchema: { programId: idParam },
412
+ annotations: READ
413
+ }, ({ programId }) => apiCall(ctx, "GET", `/3.0/coach/program/${enc(programId)}`, void 0, "This is a large, deep object. If it is truncated, fetch a narrower view (a specific session) instead."));
414
+ }
415
+ //#endregion
416
+ //#region ../core/src/tools/raw.ts
417
+ /**
418
+ * Escape hatch covering every endpoint without a dedicated tool (e.g. the analytics
419
+ * POSTs). GET is ungated; mutating methods go through the same confirmation gate as
420
+ * the dedicated destructive tools so this cannot be used to bypass it.
421
+ */
422
+ function registerRawTools(server, ctx) {
423
+ server.registerTool("th_request", {
424
+ title: "Raw TrainHeroic request",
425
+ description: "Call any TrainHeroic endpoint directly. `path` is everything after the host. `base` selects the host: 'coach' = api.trainheroic.com (default), 'apis' = apis.trainheroic.com. Prefer dedicated tools where they exist. POST/PUT/DELETE act on the live account and require confirmation.",
426
+ inputSchema: {
427
+ method: z.enum([
428
+ "GET",
429
+ "POST",
430
+ "PUT",
431
+ "DELETE"
432
+ ]),
433
+ path: z.string().min(1),
434
+ body: z.unknown().optional(),
435
+ base: z.enum(["coach", "apis"]).optional(),
436
+ confirm: z.boolean().optional()
437
+ },
438
+ annotations: DESTRUCTIVE
439
+ }, async ({ method, path, body, base, confirm }, extra) => {
440
+ if (method !== "GET") {
441
+ if (!await confirmGate(server, extra.requestId, `Run ${method} ${path} against the live TrainHeroic account?`, confirm)) return errorResult(NOT_CONFIRMED);
442
+ }
443
+ const options = {};
444
+ if (body !== void 0) options.body = body;
445
+ if (base !== void 0) options.base = base;
446
+ return apiCall(ctx, method, path, options, "Large or unfiltered response. Add query params to narrow it, or use a dedicated tool for this endpoint if one exists.");
447
+ });
448
+ }
449
+ //#endregion
450
+ //#region ../core/src/tools/exercises.ts
451
+ /**
452
+ * Exercise library tools over the ExerciseIndex — a D1 mirror on the hosted server, an
453
+ * on-disk/in-memory cache locally. The descriptions stay at the interface level so they
454
+ * read correctly on both backends.
455
+ */
456
+ function registerExerciseTools(server, ctx) {
457
+ const index = ctx.index;
458
+ server.registerTool("exercise_resolve", {
459
+ title: "Resolve exercise name",
460
+ description: "Map a name to an exercise id via the local mirror. Returns the match plus ranked candidates; when ambiguous, match is null and you should pick from candidates. Units (param_1_unit/param_2_unit) are fixed per exercise — check them before prescribing.",
461
+ inputSchema: { name: z.string().min(1) },
462
+ annotations: READ
463
+ }, ({ name }) => attempt(async () => jsonResult(await index.resolve(name))));
464
+ server.registerTool("exercise_search", {
465
+ title: "Search exercises",
466
+ description: "Ranked fuzzy search over exercise titles. Returns candidates with units.",
467
+ inputSchema: {
468
+ query: z.string().min(1),
469
+ limit: z.number().int().positive().max(100).optional()
470
+ },
471
+ annotations: READ
472
+ }, ({ query, limit }) => attempt(async () => jsonResult(await index.search(query, limit ?? 20))));
473
+ server.registerTool("exercise_get", {
474
+ title: "Get exercise",
475
+ description: "Full exercise object (with units) by id.",
476
+ inputSchema: { id: idParam },
477
+ annotations: READ
478
+ }, ({ id }) => attempt(async () => {
479
+ const ex = await index.get(toId(id));
480
+ return ex ? jsonResult(ex) : errorResult(`No exercise with id ${toId(id)}.`);
481
+ }));
482
+ server.registerTool("exercise_sync", {
483
+ title: "Sync exercise library",
484
+ description: "Refresh the cached exercise index from TrainHeroic.",
485
+ inputSchema: { force: z.boolean().optional() },
486
+ annotations: SYNC
487
+ }, ({ force }) => attempt(async () => {
488
+ if (force ?? false) return jsonResult(await index.refresh());
489
+ await index.ensureFresh();
490
+ return jsonResult(await index.stats());
491
+ }));
492
+ server.registerTool("exercise_create", {
493
+ title: "Create custom exercise",
494
+ description: "Create a custom exercise (POST /2.0/coach/exercise/create) and write it through to the mirror. Body example: {\"title\":\"Sandbag Clean\",\"param_1_type\":3,\"param_2_type\":1}.",
495
+ inputSchema: { exercise: exerciseCreateSchema },
496
+ annotations: {
497
+ readOnlyHint: false,
498
+ destructiveHint: false,
499
+ openWorldHint: true
500
+ }
501
+ }, ({ exercise }) => attempt(async () => jsonResult(await index.create(exercise))));
502
+ server.registerTool("exercise_forget", {
503
+ title: "Forget exercise (cache only)",
504
+ description: "Remove an exercise from the local mirror only. Run after deleting it via the API.",
505
+ inputSchema: { id: idParam },
506
+ annotations: {
507
+ readOnlyHint: false,
508
+ idempotentHint: true,
509
+ openWorldHint: false
510
+ }
511
+ }, ({ id }) => attempt(async () => {
512
+ await index.recordDelete(toId(id));
513
+ return jsonResult({ forgotten: toId(id) });
514
+ }));
515
+ server.registerTool("store_stats", {
516
+ title: "Exercise index stats",
517
+ description: "Row counts and sync state for the cached exercise index.",
518
+ inputSchema: {},
519
+ annotations: READ
520
+ }, () => attempt(async () => jsonResult(await index.stats())));
521
+ }
522
+ //#endregion
523
+ //#region ../js/src/auth.ts
524
+ const AUTH_URL = "https://apis.trainheroic.com/auth";
525
+ /**
526
+ * Authenticate against TrainHeroic. Returns the session bundle, or null on bad
527
+ * credentials. TrainHeroic returns only { id, scope, role, session_id } (verified in
528
+ * the Phase 0 spike: no refresh_token, no api_token, no TTL). The 48-char session_id
529
+ * is sent as the `session-token` header and works against both API hosts.
530
+ */
531
+ async function loginTrainHeroic(email, password) {
532
+ const res = await fetch(AUTH_URL, {
533
+ method: "POST",
534
+ headers: {
535
+ "content-type": "application/x-www-form-urlencoded",
536
+ accept: "application/json"
537
+ },
538
+ body: new URLSearchParams({
539
+ email,
540
+ password
541
+ }).toString()
542
+ });
543
+ if (!res.ok) return null;
544
+ const data = await res.json().catch(() => null);
545
+ if (!data || typeof data.id !== "number" || !data.session_id) return null;
546
+ return {
547
+ thUserId: data.id,
548
+ sessionId: data.session_id,
549
+ scope: data.scope ?? "",
550
+ role: data.role ?? ""
551
+ };
552
+ }
553
+ //#endregion
554
+ //#region ../js/src/client.ts
555
+ const COACH_BASE = "https://api.trainheroic.com";
556
+ const APIS_BASE = "https://apis.trainheroic.com";
557
+ var TrainHeroicAuthError = class extends Error {
558
+ name = "TrainHeroicAuthError";
559
+ };
560
+ /**
561
+ * Authenticated TrainHeroic API client. Holds the coach credentials (from the grant's
562
+ * encrypted props) and a lazily-acquired session token cached in memory for the life
563
+ * of the Durable Object instance. On a 401/403 it re-logs in once and retries, since
564
+ * TrainHeroic has no refresh token and sessions expire after ~1-2h.
565
+ */
566
+ var TrainHeroicClient = class {
567
+ #email;
568
+ #password;
569
+ #sessionId;
570
+ #loginInFlight = null;
571
+ constructor(email, password, sessionId = null) {
572
+ this.#email = email;
573
+ this.#password = password;
574
+ this.#sessionId = sessionId;
575
+ }
576
+ get sessionId() {
577
+ return this.#sessionId;
578
+ }
579
+ async #ensureSession() {
580
+ if (this.#sessionId) return this.#sessionId;
581
+ this.#loginInFlight ??= this.#login();
582
+ try {
583
+ return await this.#loginInFlight;
584
+ } finally {
585
+ this.#loginInFlight = null;
586
+ }
587
+ }
588
+ async #login() {
589
+ const session = await loginTrainHeroic(this.#email, this.#password);
590
+ if (!session) throw new TrainHeroicAuthError("TrainHeroic login failed");
591
+ this.#sessionId = session.sessionId;
592
+ return this.#sessionId;
593
+ }
594
+ async request(method, path, options = {}) {
595
+ const url = `${options.base === "apis" ? APIS_BASE : COACH_BASE}/${path.replace(/^\//, "")}`;
596
+ let session = await this.#ensureSession();
597
+ let res = await this.#send(method, url, session, options.body);
598
+ if (res.status === 401 || res.status === 403) {
599
+ if (this.#sessionId === session) this.#sessionId = null;
600
+ session = await this.#ensureSession();
601
+ res = await this.#send(method, url, session, options.body);
602
+ }
603
+ const text = await res.text();
604
+ let data = text;
605
+ if (text.length > 0) try {
606
+ data = JSON.parse(text);
607
+ } catch {
608
+ data = text;
609
+ }
610
+ return {
611
+ status: res.status,
612
+ ok: res.ok,
613
+ data
614
+ };
615
+ }
616
+ #send(method, url, session, body) {
617
+ const upper = method.toUpperCase();
618
+ const headers = {
619
+ accept: "application/json",
620
+ "session-token": session
621
+ };
622
+ const init = {
623
+ method: upper,
624
+ headers
625
+ };
626
+ if (body !== void 0 && upper !== "GET" && upper !== "DELETE") {
627
+ headers["content-type"] = "application/json";
628
+ init.body = JSON.stringify(body);
629
+ }
630
+ return fetch(url, init);
631
+ }
632
+ };
633
+ //#endregion
634
+ //#region ../js/src/response-check.ts
635
+ /**
636
+ * Validate an API response against its (loose) expected shape and warn once on drift.
637
+ * Never throws: callers keep working via defensive coercion. This only surfaces a signal
638
+ * when TrainHeroic renames or drops a field we read.
639
+ */
640
+ function checkResponse(schema, data, label) {
641
+ const result = schema.safeParse(data);
642
+ if (result.success) return;
643
+ const issue = result.error.issues[0];
644
+ const where = issue && issue.path.length > 0 ? issue.path.join(".") : "(root)";
645
+ console.warn(`[trainheroic] response drift in ${label} at ${where}: ${issue?.message ?? "shape mismatch"}`);
646
+ }
647
+ //#endregion
648
+ //#region ../js/src/exercise-util.ts
649
+ /**
650
+ * Display labels for TrainHeroic parameter types. The unit is FIXED PER EXERCISE
651
+ * (the API forces param_1_type/param_2_type back to the library default on save), so
652
+ * resolve/search surface it to stop callers picking, say, the miles "Run" for a
653
+ * metric workout. Keep in sync with the workout builder's unit table.
654
+ */
655
+ const PARAM_UNIT = {
656
+ 0: null,
657
+ 1: "lb",
658
+ 2: "%max",
659
+ 3: "reps",
660
+ 4: "sec",
661
+ 5: "yd",
662
+ 6: "m",
663
+ 7: "in",
664
+ 10: "mi",
665
+ 11: "ft",
666
+ 12: "in",
667
+ 13: "bpm",
668
+ 14: "RPE",
669
+ 18: "sec"
670
+ };
671
+ function coerceInt(value) {
672
+ if (typeof value === "boolean") return value ? 1 : 0;
673
+ if (typeof value === "number") return Number.isFinite(value) ? Math.trunc(value) : null;
674
+ if (typeof value === "string" && value.trim() !== "") {
675
+ const n = Number(value);
676
+ return Number.isFinite(n) ? Math.trunc(n) : null;
677
+ }
678
+ return null;
679
+ }
680
+ function unitLabel(paramType) {
681
+ const t = coerceInt(paramType);
682
+ if (t === null) return null;
683
+ return PARAM_UNIT[t] ?? null;
684
+ }
685
+ /** Annotate a row with human-readable units for display. */
686
+ function withUnits(row) {
687
+ return {
688
+ ...row,
689
+ param_1_unit: unitLabel(row.param_1_type),
690
+ param_2_unit: unitLabel(row.param_2_type)
691
+ };
692
+ }
693
+ function buildSearchText(title) {
694
+ return title.trim().toLowerCase();
695
+ }
696
+ /** Strip the {"success":1,"data":X} envelope some 2.0/coach endpoints use. */
697
+ function unwrapEnvelope(body) {
698
+ if (body && typeof body === "object" && !Array.isArray(body)) {
699
+ const obj = body;
700
+ const keys = new Set(Object.keys(obj));
701
+ const envelope = /* @__PURE__ */ new Set([
702
+ "success",
703
+ "data",
704
+ "message",
705
+ "error"
706
+ ]);
707
+ if ("data" in obj && [...keys].every((k) => envelope.has(k))) return obj.data;
708
+ }
709
+ return body;
710
+ }
711
+ /** Pull the exercise array out of whatever shape the bulk endpoint returns. */
712
+ function asExerciseList(body) {
713
+ const unwrapped = unwrapEnvelope(body);
714
+ if (Array.isArray(unwrapped)) return unwrapped.filter((x) => isRecord(x));
715
+ if (isRecord(unwrapped)) {
716
+ const items = [];
717
+ for (const key of [
718
+ "exercises",
719
+ "circuits",
720
+ "workoutCircuits",
721
+ "library",
722
+ "items",
723
+ "results"
724
+ ]) {
725
+ const value = unwrapped[key];
726
+ if (Array.isArray(value)) items.push(...value.filter((x) => isRecord(x)));
727
+ }
728
+ if (items.length > 0) return items;
729
+ const values = Object.values(unwrapped);
730
+ if (values.length > 0 && values.every(isRecord)) return values;
731
+ }
732
+ return [];
733
+ }
734
+ function isRecord(x) {
735
+ return typeof x === "object" && x !== null && !Array.isArray(x);
736
+ }
737
+ /**
738
+ * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
739
+ * exact title, then prefix, then count of matched tokens, with shorter titles and
740
+ * standard (non-custom) exercises preferred on ties.
741
+ */
742
+ function rankSearch(rows, query, limit) {
743
+ const q = query.trim().toLowerCase();
744
+ const tokens = q.split(/\s+/u).filter((t) => t.length > 0);
745
+ return rows.map((row) => {
746
+ const title = row.title.toLowerCase();
747
+ let score = 0;
748
+ if (title === q) score += 1e3;
749
+ if (title.startsWith(q)) score += 100;
750
+ for (const tok of tokens) if (title.includes(tok)) score += 10;
751
+ score -= title.length * .05;
752
+ if (row.can_edit === 0) score += 1;
753
+ return {
754
+ row,
755
+ score
756
+ };
757
+ }).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
758
+ }
759
+ //#endregion
760
+ //#region ../js/src/workout-encode.ts
761
+ const LEADERBOARD_TYPE = {
762
+ completion: 0,
763
+ "for completion": 0,
764
+ weight: 1,
765
+ lb: 1,
766
+ load: 1,
767
+ reps: 2,
768
+ rep: 2,
769
+ rounds: 3,
770
+ round: 3,
771
+ time: 4,
772
+ yards: 5,
773
+ yd: 5,
774
+ meters: 6,
775
+ m: 6,
776
+ feet: 7,
777
+ ft: 7,
778
+ calories: 8,
779
+ cal: 8,
780
+ cals: 8,
781
+ miles: 10,
782
+ mi: 10,
783
+ inches: 12,
784
+ in: 12,
785
+ watts: 15,
786
+ w: 15,
787
+ velocity: 17,
788
+ "m/s": 17,
789
+ seconds: 18,
790
+ sec: 18,
791
+ s: 18
792
+ };
793
+ const LEADERBOARD_LABEL = {
794
+ 0: "For Completion",
795
+ 1: "Weight",
796
+ 2: "Reps",
797
+ 3: "Rounds",
798
+ 4: "Time",
799
+ 5: "Yards",
800
+ 6: "Meters",
801
+ 7: "Feet",
802
+ 8: "Calories",
803
+ 10: "Miles",
804
+ 12: "Inches",
805
+ 13: "Other",
806
+ 15: "Watts",
807
+ 16: "Percent",
808
+ 17: "Velocity",
809
+ 18: "Seconds"
810
+ };
811
+ function resolveLeaderboard(block) {
812
+ const lb = block.leaderboard;
813
+ if (lb === void 0 || lb === null) return {
814
+ isRedzone: null,
815
+ redzoneType: 0,
816
+ smallerIsBetter: null,
817
+ redzoneInstruction: ""
818
+ };
819
+ let unit;
820
+ let instruction = "";
821
+ let lowest;
822
+ if (typeof lb === "object") {
823
+ unit = lb.unit ?? lb.type;
824
+ instruction = lb.instruction ?? "";
825
+ lowest = lb.lowest_wins;
826
+ } else unit = lb;
827
+ let rz;
828
+ if (typeof unit === "string") {
829
+ const found = LEADERBOARD_TYPE[unit.trim().toLowerCase()];
830
+ if (found === void 0) throw new Error(`Unknown leaderboard unit '${unit}'. Use one of: ${Object.keys(LEADERBOARD_TYPE).join(", ")}.`);
831
+ rz = found;
832
+ } else if (typeof unit === "number") rz = Math.trunc(unit);
833
+ else throw new Error("Leaderboard requires a unit.");
834
+ if (lowest === void 0) lowest = rz === 4 || rz === 18;
835
+ return {
836
+ isRedzone: 1,
837
+ redzoneType: rz,
838
+ smallerIsBetter: lowest ? 1 : 0,
839
+ redzoneInstruction: instruction
840
+ };
841
+ }
842
+ function slots(values, n = 10) {
843
+ const out = [];
844
+ for (let i = 0; i < n; i += 1) out.push(values && i < values.length ? values[i] ?? "" : "");
845
+ return out;
846
+ }
847
+ function repsList(ex) {
848
+ const reps = ex.reps;
849
+ if (Array.isArray(reps)) return reps.map((r) => String(r));
850
+ if (reps === void 0 || reps === null) return [];
851
+ const sets = Math.max(1, Math.trunc(Number(ex.sets ?? 1)) || 1);
852
+ return Array.from({ length: sets }, () => String(reps));
853
+ }
854
+ /** Build one saveWorkoutSetExercises payload entry with all ten param slots filled. */
855
+ function makeExercise(ex, workoutSetId, order, key) {
856
+ const reps = repsList(ex);
857
+ let instruction = ex.instr ?? "";
858
+ if (instruction === "" && ex.rpe !== void 0 && ex.rpe !== null) instruction = `RPE ${ex.rpe}`;
859
+ const hasWeight = ex.weight !== void 0 && ex.weight !== null;
860
+ const weightArr = Array.isArray(ex.weight) ? ex.weight : null;
861
+ let count = reps.length;
862
+ if (count === 0 && hasWeight) count = weightArr ? weightArr.length : Math.max(1, Math.trunc(Number(ex.sets ?? 1)) || 1);
863
+ let param2Type;
864
+ let param2Values;
865
+ if (hasWeight) {
866
+ param2Type = ex.param_2_type ?? 1;
867
+ param2Values = (weightArr ?? Array.from({ length: count }, () => ex.weight)).map((v) => String(v));
868
+ } else {
869
+ param2Type = 0;
870
+ param2Values = null;
871
+ }
872
+ const entry = {
873
+ exercise_id: ex.id,
874
+ workout_set_id: workoutSetId,
875
+ set_id: workoutSetId,
876
+ setKey: workoutSetId,
877
+ title: ex.title ?? "",
878
+ instruction,
879
+ order,
880
+ param_1_type: ex.param_1_type ?? 3,
881
+ param_2_type: param2Type,
882
+ workout_set_exercise_template_id: null,
883
+ no_sets: 0,
884
+ param_count: count,
885
+ set_num: count,
886
+ key,
887
+ video_url: "",
888
+ thumbnail_url: "",
889
+ tags: [],
890
+ eType: "e",
891
+ use_count: 0
892
+ };
893
+ const p1 = slots(reps);
894
+ const p2 = slots(param2Values);
895
+ for (let i = 0; i < 10; i += 1) {
896
+ entry[`param_1_data_${i + 1}`] = p1[i] ?? "";
897
+ entry[`param_2_data_${i + 1}`] = p2[i] ?? "";
898
+ }
899
+ return entry;
900
+ }
901
+ function buildBlockPayload(blocks, workoutId) {
902
+ return blocks.map((b, i) => {
903
+ const lb = resolveLeaderboard(b);
904
+ return {
905
+ workout_id: workoutId,
906
+ order: i + 1,
907
+ type: b.type ?? 2,
908
+ instruction: b.instruction ?? "",
909
+ is_redzone: lb.isRedzone,
910
+ redzone_type: lb.redzoneType,
911
+ smaller_is_better: lb.smallerIsBetter,
912
+ redzone_instruction: lb.redzoneInstruction,
913
+ exercises: [],
914
+ exerciseKeys: [],
915
+ key: `k::${workoutId}${i + 1}`,
916
+ title: b.title
917
+ };
918
+ });
919
+ }
920
+ function unitOr(t) {
921
+ return unitLabel(t) ?? "?";
922
+ }
923
+ /** Flag spec params the API will silently override to the exercise's fixed units. */
924
+ function unitAdvisory(blockTitle, ex, defaults) {
925
+ const notes = [];
926
+ const warnings = [];
927
+ const u = unitOr;
928
+ const label = `${blockTitle} / ${ex.title ?? ex.id}`;
929
+ 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).`);
930
+ const sentP1 = ex.param_1_type;
931
+ if (sentP1 !== void 0 && sentP1 !== null && Math.trunc(Number(sentP1)) !== defaults.param1) {
932
+ const sp1 = Math.trunc(Number(sentP1));
933
+ 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)}.`);
934
+ } else if (defaults.param1 !== 3 && defaults.param1 !== null) notes.push(`${label}: values are in ${u(defaults.param1)} (the exercise's fixed primary unit).`);
935
+ if (ex.weight !== void 0 && ex.weight !== null) {
936
+ const sentP2 = Math.trunc(Number(ex.param_2_type ?? 1));
937
+ const effP2 = defaults.param2 === 0 || defaults.param2 === null ? 1 : defaults.param2;
938
+ 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.`);
939
+ else warnings.push(`${label}: load renders as ${u(effP2)}, not ${u(sentP2)} (this exercise's secondary unit is fixed).`);
940
+ }
941
+ return {
942
+ notes,
943
+ warnings
944
+ };
945
+ }
946
+ /**
947
+ * Run unit advisories across a whole block list against an exercise index. Shared by the
948
+ * MCP workout_build tool and the CLI so both surface the same notes/warnings. Ensures the
949
+ * index is loaded first, otherwise `defaults` returns null on a cold index and every
950
+ * advisory is silently dropped.
951
+ */
952
+ async function collectAdvisories(blocks, index) {
953
+ await index.ensureFresh();
954
+ const pairs = blocks.flatMap((b) => b.exercises.map((ex) => ({
955
+ block: b,
956
+ ex
957
+ })));
958
+ const defaults = await Promise.all(pairs.map((p) => {
959
+ const id = Number(p.ex.id);
960
+ return Number.isFinite(id) ? index.defaults(id) : Promise.resolve(null);
961
+ }));
962
+ const notes = [];
963
+ const warnings = [];
964
+ pairs.forEach((p, i) => {
965
+ const def = defaults[i];
966
+ if (!def) return;
967
+ const advisory = unitAdvisory(p.block.title, p.ex, def);
968
+ notes.push(...advisory.notes);
969
+ warnings.push(...advisory.warnings);
970
+ });
971
+ return {
972
+ notes,
973
+ warnings
974
+ };
975
+ }
976
+ //#endregion
977
+ //#region ../js/src/workout-session.ts
978
+ async function req(client, method, path, body) {
979
+ const res = await client.request(method, path, body === void 0 ? void 0 : { body });
980
+ if (!res.ok) {
981
+ const detail = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
982
+ throw new Error(`${method} ${path} failed (HTTP ${res.status}): ${detail}`);
983
+ }
984
+ return res.data;
985
+ }
986
+ function createPath(opts) {
987
+ if (opts.timelineDay !== void 0) return `/2.0/coach/calendar/workout/createWorkoutForTimelineDay/${opts.programId}/${opts.timelineDay}/null`;
988
+ if (!opts.date) throw new Error("workout build requires either date or timelineDay");
989
+ const [y, m, d] = opts.date;
990
+ return `/2.0/coach/calendar/workout/createWorkoutForDay/${opts.programId}/${y}/${m}/${d}/0`;
991
+ }
992
+ async function buildSession(client, opts) {
993
+ const sess = await req(client, "POST", createPath(opts), {});
994
+ checkResponse(sessionCreateResponseSchema, sess, "session create");
995
+ const workoutId = Number(sess.workout_id);
996
+ const pwId = Number(sess.id);
997
+ const created = await req(client, "POST", "/2.0/coach/calendar/saveProgramWorkoutSets", buildBlockPayload(opts.blocks, workoutId));
998
+ const byOrder = new Map(created.map((b) => [b.order, b.id]));
999
+ let counter = 0;
1000
+ const payloads = opts.blocks.map((block, i) => {
1001
+ const wsid = byOrder.get(i + 1);
1002
+ if (wsid === void 0) throw new Error(`No saved block for order ${i + 1}.`);
1003
+ return block.exercises.map((ex, j) => {
1004
+ counter += 1;
1005
+ return makeExercise(ex, wsid, j + 1, `k::${workoutId}${String(counter).padStart(3, "0")}`);
1006
+ });
1007
+ });
1008
+ await Promise.all(payloads.map((p) => req(client, "POST", "/2.0/coach/calendar/saveWorkoutSetExercises", p)));
1009
+ if (opts.instruction !== void 0 && opts.instruction !== "") {
1010
+ const blockIds = [...byOrder.entries()].sort((a, b) => a[0] - b[0]).map(([, id]) => id);
1011
+ await setSessionInstruction(client, workoutId, sess, opts.instruction, blockIds);
1012
+ }
1013
+ if (opts.publish ?? false) await req(client, "POST", "/2.0/coach/calendar/programWorkout/publish", [pwId]);
1014
+ return {
1015
+ pwId,
1016
+ workoutId
1017
+ };
1018
+ }
1019
+ /**
1020
+ * Set a session's Coach Instructions (the day-note at the top of a session). `pw` is the
1021
+ * programWorkout object (the create-time response or a day's edit-GET entry). The PUT wants
1022
+ * the whole object back with `instruction` set and `sets`/`setKeys` as a flat list of block
1023
+ * ids. This does NOT change publish state: `published` is sent exactly as it is on `pw`.
1024
+ */
1025
+ async function setSessionInstruction(client, workoutId, pw, instruction, blockIds) {
1026
+ const body = {
1027
+ ...pw,
1028
+ instruction,
1029
+ sets: blockIds,
1030
+ setKeys: blockIds
1031
+ };
1032
+ await req(client, "PUT", `/3.0/coach/workout/${workoutId}`, body);
1033
+ }
1034
+ async function removeSession(client, programId, pwId) {
1035
+ await req(client, "POST", "/2.0/coach/calendar/removeProgramWorkout", {
1036
+ programId,
1037
+ pwId
1038
+ });
1039
+ }
1040
+ async function publishSession(client, pwId) {
1041
+ await req(client, "POST", "/2.0/coach/calendar/programWorkout/publish", [pwId]);
1042
+ }
1043
+ function str(value) {
1044
+ return value === void 0 || value === null ? "" : String(value);
1045
+ }
1046
+ async function readSession(client, programId, date, pwId) {
1047
+ const [y, m, d] = date;
1048
+ const data = await req(client, "GET", `/1.0/coach/programs/edit/${programId}/${y}/${m}/${d}`);
1049
+ checkResponse(programsEditResponseSchema, data, "programs edit");
1050
+ const pw = (data.programWorkouts ?? []).find((p) => coerceInt(p.id) === pwId);
1051
+ if (!pw) throw new Error(`programWorkout ${pwId} not found on ${y}-${m}-${d}.`);
1052
+ const setsObj = pw.sets ?? {};
1053
+ const blocks = Object.values(setsObj).sort((a, b) => Number(a.order) - Number(b.order)).map((b) => readBlock(b));
1054
+ return {
1055
+ pwId,
1056
+ date: `${str(pw.year)}-${str(pw.month)}-${str(pw.day)}`,
1057
+ published: pw.published,
1058
+ instruction: str(pw.instruction),
1059
+ blocks
1060
+ };
1061
+ }
1062
+ function readBlock(b) {
1063
+ const rz = coerceInt(b.redzone_type);
1064
+ let leaderboard = null;
1065
+ if (rz && rz > 0) leaderboard = `FOR ${(LEADERBOARD_LABEL[rz] ?? `type ${rz}`).toUpperCase()}${b.smaller_is_better ? " (lowest wins)" : ""}`;
1066
+ const exercises = (Array.isArray(b.exercises) ? b.exercises : []).sort((a, e) => Number(a.order) - Number(e.order)).map((ex) => readExercise(ex));
1067
+ return {
1068
+ order: Number(b.order),
1069
+ title: str(b.title),
1070
+ leaderboard,
1071
+ exercises
1072
+ };
1073
+ }
1074
+ function readExercise(ex) {
1075
+ const reps = [];
1076
+ const load = [];
1077
+ for (let i = 1; i <= 10; i += 1) {
1078
+ const r = str(ex[`param_1_data_${i}`]);
1079
+ if (r !== "") reps.push(r);
1080
+ const w = str(ex[`param_2_data_${i}`]);
1081
+ if (w !== "") load.push(w);
1082
+ }
1083
+ return {
1084
+ order: Number(ex.order),
1085
+ title: str(ex.title),
1086
+ reps,
1087
+ primaryUnit: unitLabel(coerceInt(ex.param_1_type)),
1088
+ load,
1089
+ loadUnit: unitLabel(coerceInt(ex.param_2_type)),
1090
+ instruction: str(ex.instruction)
1091
+ };
1092
+ }
1093
+ //#endregion
1094
+ //#region ../js/src/messaging.ts
1095
+ const BUCKETS = [
1096
+ ["team", "teams"],
1097
+ ["athlete", "athletes"],
1098
+ ["program", "programs"],
1099
+ ["coach", "coaches"]
1100
+ ];
1101
+ /** Live list of chat streams, flattened to (stream, kind) tuples. */
1102
+ async function fetchStreams(client) {
1103
+ const res = await client.request("GET", "/v5/messaging/streams");
1104
+ if (!res.ok || !isRecord(res.data)) throw new Error(`GET /v5/messaging/streams failed (HTTP ${res.status}).`);
1105
+ const out = [];
1106
+ for (const [kind, key] of BUCKETS) {
1107
+ const bucket = res.data[key];
1108
+ if (Array.isArray(bucket)) {
1109
+ for (const s of bucket) if (isRecord(s) && coerceInt(s.id) !== null) out.push({
1110
+ stream: s,
1111
+ kind
1112
+ });
1113
+ }
1114
+ }
1115
+ return out;
1116
+ }
1117
+ /**
1118
+ * The exact chat comment body the web app sends. The non-obvious required field is
1119
+ * `feed_id` (the stream id repeated in the body); omitting it returns 400.
1120
+ */
1121
+ function buildCommentPayload(streamId, text, replyTo = null) {
1122
+ return {
1123
+ type: 0,
1124
+ content: text,
1125
+ photo_url: "",
1126
+ photoUrl: "",
1127
+ access_level: 0,
1128
+ parent_feed_item_id: replyTo,
1129
+ feed_id: streamId
1130
+ };
1131
+ }
1132
+ async function sendComment(client, streamId, text, replyTo = null) {
1133
+ const res = await client.request("POST", `/v5/messaging/streams/${streamId}/comments`, { body: buildCommentPayload(streamId, text, replyTo) });
1134
+ if (!res.ok || typeof res.data !== "object" || res.data === null || res.data.id === void 0) throw new Error(`Message send failed (HTTP ${res.status}).`);
1135
+ return res.data;
1136
+ }
1137
+ async function deleteComment(client, streamId, commentId) {
1138
+ const res = await client.request("DELETE", `/v5/messaging/streams/${streamId}/comments/${commentId}`);
1139
+ if (!res.ok) throw new Error(`Message delete failed (HTTP ${res.status}).`);
1140
+ return res.data;
1141
+ }
1142
+ async function readLive(client, streamId, limit) {
1143
+ const res = await client.request("GET", `/v5/messaging/streams/${streamId}/comments?lastCommentId=`);
1144
+ if (!res.ok || !Array.isArray(res.data)) throw new Error(`Message read failed (HTTP ${res.status}).`);
1145
+ return limit !== void 0 && limit > 0 ? res.data.slice(-limit) : res.data;
1146
+ }
1147
+ //#endregion
1148
+ //#region ../js/src/library-cache.ts
1149
+ /** In-memory cache (no persistence). The default when none is supplied. */
1150
+ var MemoryLibraryCache = class {
1151
+ #snapshot = null;
1152
+ async load() {
1153
+ return this.#snapshot;
1154
+ }
1155
+ async save(snapshot) {
1156
+ this.#snapshot = snapshot;
1157
+ }
1158
+ };
1159
+ //#endregion
1160
+ //#region ../js/src/exercise-index.ts
1161
+ const LIBRARY_PATH = "/v5/exerciseLibrary/all";
1162
+ const CREATE_PATH = "/2.0/coach/exercise/create";
1163
+ const TTL_MS = 168 * 3600 * 1e3;
1164
+ function toStored(ex) {
1165
+ const id = coerceInt(ex.id);
1166
+ if (id === null) return null;
1167
+ const title = String(ex.title ?? "");
1168
+ return {
1169
+ id,
1170
+ title,
1171
+ search: buildSearchText(title),
1172
+ param_1_type: coerceInt(ex.param_1_type),
1173
+ param_2_type: coerceInt(ex.param_2_type),
1174
+ can_edit: coerceInt(ex.can_edit) ?? 0,
1175
+ user_id: coerceInt(ex.user_id),
1176
+ use_count: coerceInt(ex.use_count) ?? 0,
1177
+ raw: ex
1178
+ };
1179
+ }
1180
+ function toRow(s) {
1181
+ return {
1182
+ id: s.id,
1183
+ title: s.title,
1184
+ param_1_type: s.param_1_type,
1185
+ param_2_type: s.param_2_type,
1186
+ can_edit: s.can_edit,
1187
+ user_id: s.user_id,
1188
+ use_count: s.use_count
1189
+ };
1190
+ }
1191
+ /**
1192
+ * The exercise library held in memory for fast queries and persisted through a
1193
+ * LibraryCache (JSON file for a CLI/local server, in-memory by default). Same
1194
+ * resolve/search/unit behavior as the D1-backed store, with no database.
1195
+ */
1196
+ var ExerciseLibrary = class {
1197
+ #client;
1198
+ #cache;
1199
+ #byId = /* @__PURE__ */ new Map();
1200
+ #loaded = false;
1201
+ #fetchedAt = 0;
1202
+ constructor(client, cache = new MemoryLibraryCache()) {
1203
+ this.#client = client;
1204
+ this.#cache = cache;
1205
+ }
1206
+ #hydrate(list, fetchedAt) {
1207
+ const next = /* @__PURE__ */ new Map();
1208
+ for (const ex of list) {
1209
+ const s = toStored(ex);
1210
+ if (s) next.set(s.id, s);
1211
+ }
1212
+ this.#byId = next;
1213
+ this.#fetchedAt = fetchedAt;
1214
+ this.#loaded = true;
1215
+ }
1216
+ async #ensureLoaded() {
1217
+ if (this.#loaded) return;
1218
+ const snap = await this.#cache.load();
1219
+ if (snap && snap.exercises.length > 0 && Date.now() - snap.fetchedAt <= TTL_MS) this.#hydrate(snap.exercises, snap.fetchedAt);
1220
+ else await this.refresh();
1221
+ }
1222
+ async #persist() {
1223
+ await this.#cache.save({
1224
+ fetchedAt: this.#fetchedAt,
1225
+ exercises: [...this.#byId.values()].map((s) => s.raw)
1226
+ });
1227
+ }
1228
+ async ensureFresh() {
1229
+ if (!this.#loaded) await this.#ensureLoaded();
1230
+ else if (Date.now() - this.#fetchedAt > TTL_MS) await this.refresh();
1231
+ }
1232
+ async refresh() {
1233
+ const res = await this.#client.request("GET", LIBRARY_PATH);
1234
+ if (!res.ok) throw new Error(`Exercise library fetch failed (HTTP ${res.status}).`);
1235
+ const list = asExerciseList(res.data);
1236
+ if (list.length === 0) throw new Error("Exercise library returned no rows; keeping the cache.");
1237
+ checkResponse(exerciseLibraryResponseSchema, list, "exercise library");
1238
+ this.#hydrate(list, Date.now());
1239
+ await this.#persist();
1240
+ return { synced: this.#byId.size };
1241
+ }
1242
+ async get(id) {
1243
+ await this.ensureFresh();
1244
+ const s = this.#byId.get(id);
1245
+ if (!s) return null;
1246
+ const full = { ...s.raw };
1247
+ full.param_1_unit = unitLabel(full.param_1_type);
1248
+ full.param_2_unit = unitLabel(full.param_2_type);
1249
+ return full;
1250
+ }
1251
+ async defaults(id) {
1252
+ const s = this.#byId.get(id);
1253
+ return s ? {
1254
+ param1: s.param_1_type,
1255
+ param2: s.param_2_type
1256
+ } : null;
1257
+ }
1258
+ async search(query, limit = 20) {
1259
+ await this.ensureFresh();
1260
+ return this.#searchOnly(query, limit);
1261
+ }
1262
+ #searchOnly(query, limit) {
1263
+ const tokens = query.toLowerCase().split(/\s+/u).filter((t) => t.length > 0);
1264
+ if (tokens.length === 0) return [];
1265
+ return rankSearch([...this.#byId.values()].filter((s) => tokens.every((t) => s.search.includes(t))).map((s) => toRow(s)), query, limit).map(withUnits);
1266
+ }
1267
+ #exact(name) {
1268
+ const q = name.trim().toLowerCase();
1269
+ const first = [...this.#byId.values()].filter((s) => s.search === q).sort((a, b) => a.can_edit - b.can_edit)[0];
1270
+ return first ? withUnits(toRow(first)) : null;
1271
+ }
1272
+ async resolve(name) {
1273
+ await this.ensureFresh();
1274
+ let hit = this.#exact(name);
1275
+ if (hit) return {
1276
+ match: hit,
1277
+ candidates: [hit]
1278
+ };
1279
+ let candidates = this.#searchOnly(name, 20);
1280
+ if (candidates.length === 0) {
1281
+ await this.refresh();
1282
+ hit = this.#exact(name);
1283
+ if (hit) return {
1284
+ match: hit,
1285
+ candidates: [hit]
1286
+ };
1287
+ candidates = this.#searchOnly(name, 20);
1288
+ }
1289
+ if (candidates.length === 1) return {
1290
+ match: candidates[0] ?? null,
1291
+ candidates
1292
+ };
1293
+ return {
1294
+ match: null,
1295
+ candidates
1296
+ };
1297
+ }
1298
+ async create(body) {
1299
+ await this.ensureFresh();
1300
+ const res = await this.#client.request("POST", CREATE_PATH, { body });
1301
+ if (!res.ok) throw new Error(`Exercise create failed (HTTP ${res.status}).`);
1302
+ const ex = unwrapEnvelope(res.data);
1303
+ if (ex && typeof ex === "object") {
1304
+ checkResponse(exerciseResponseSchema, ex, "exercise create");
1305
+ const s = toStored(ex);
1306
+ if (s) {
1307
+ this.#byId.set(s.id, s);
1308
+ await this.#persist();
1309
+ }
1310
+ }
1311
+ return ex;
1312
+ }
1313
+ async recordDelete(id) {
1314
+ await this.ensureFresh();
1315
+ if (this.#byId.delete(id)) await this.#persist();
1316
+ }
1317
+ async stats() {
1318
+ let custom = 0;
1319
+ for (const s of this.#byId.values()) if (s.can_edit === 1) custom += 1;
1320
+ return {
1321
+ exercises: this.#byId.size,
1322
+ custom,
1323
+ loaded: this.#loaded,
1324
+ fetchedAt: this.#fetchedAt
1325
+ };
1326
+ }
1327
+ };
1328
+ //#endregion
1329
+ //#region ../core/src/tools/workout.ts
1330
+ /** Workout building, read-back, publishing, and removal. */
1331
+ function registerWorkoutTools(server, ctx) {
1332
+ server.registerTool("workout_build", {
1333
+ title: "Build a workout session (draft)",
1334
+ description: "Build an UNPUBLISHED session from a spec (program -> session -> blocks -> exercises). Two exercises in one block become a superset. Add a block 'leaderboard' for a Red-Zone score, or a top-level 'instruction' for the session note (Coach Instructions). Returns the draft ids, a read-back, and unit advisories. Review, then workout_publish.",
1335
+ inputSchema: {
1336
+ programId: z.number(),
1337
+ date: z.string().optional(),
1338
+ timelineDay: z.number().optional(),
1339
+ blocks: z.array(blockSpecSchema),
1340
+ instruction: z.string().optional()
1341
+ },
1342
+ annotations: {
1343
+ readOnlyHint: false,
1344
+ destructiveHint: false,
1345
+ openWorldHint: true
1346
+ }
1347
+ }, ({ programId, date, timelineDay, blocks, instruction }) => attempt(async () => {
1348
+ if (date === void 0 && timelineDay === void 0) return errorResult("Provide either date (YYYY-M-D) or timelineDay.");
1349
+ const typed = blocks;
1350
+ const opts = {
1351
+ programId,
1352
+ blocks: typed,
1353
+ publish: false
1354
+ };
1355
+ if (date !== void 0) opts.date = parseWorkoutDate(date);
1356
+ if (timelineDay !== void 0) opts.timelineDay = timelineDay;
1357
+ if (instruction !== void 0) opts.instruction = instruction;
1358
+ const advisories = await collectAdvisories(typed, ctx.index);
1359
+ const built = await buildSession(ctx.client, opts);
1360
+ const readback = opts.date ? await readSession(ctx.client, programId, opts.date, built.pwId) : null;
1361
+ return jsonResult({
1362
+ ...built,
1363
+ published: false,
1364
+ advisories,
1365
+ readback,
1366
+ note: "Draft created (unpublished). Review, then call workout_publish to make it athlete-facing."
1367
+ });
1368
+ }));
1369
+ server.registerTool("workout_read", {
1370
+ title: "Read a built session",
1371
+ description: "Read-back a session by programId, date (YYYY-M-D), and programWorkout id.",
1372
+ inputSchema: {
1373
+ programId: z.number(),
1374
+ date: z.string(),
1375
+ pwId: z.number()
1376
+ },
1377
+ annotations: {
1378
+ readOnlyHint: true,
1379
+ openWorldHint: true
1380
+ }
1381
+ }, ({ programId, date, pwId }) => attempt(async () => jsonResult(await readSession(ctx.client, programId, parseWorkoutDate(date), pwId))));
1382
+ server.registerTool("workout_publish", {
1383
+ title: "Publish a session",
1384
+ description: "Publish a built session — ATHLETE-FACING and immediate. Requires confirmation (elicitation, or confirm:true).",
1385
+ inputSchema: {
1386
+ programId: z.number(),
1387
+ date: z.string(),
1388
+ pwId: z.number(),
1389
+ confirm: z.boolean().optional()
1390
+ },
1391
+ annotations: {
1392
+ readOnlyHint: false,
1393
+ destructiveHint: true,
1394
+ openWorldHint: true
1395
+ }
1396
+ }, ({ programId, date, pwId, confirm }, extra) => attempt(async () => {
1397
+ if (!await confirmGate(server, extra.requestId, `Publish session ${pwId} on ${date}? This is athlete-facing and immediate.`, confirm)) return errorResult(NOT_CONFIRMED);
1398
+ await publishSession(ctx.client, pwId);
1399
+ return jsonResult({
1400
+ published: pwId,
1401
+ readback: await readSession(ctx.client, programId, parseWorkoutDate(date), pwId)
1402
+ });
1403
+ }));
1404
+ server.registerTool("session_remove", {
1405
+ title: "Remove a session",
1406
+ description: "Delete a session from the live calendar (also the way to replace a date: remove then build). Hard to undo. Requires confirmation (elicitation, or confirm:true).",
1407
+ inputSchema: {
1408
+ programId: z.number(),
1409
+ pwId: z.number(),
1410
+ confirm: z.boolean().optional()
1411
+ },
1412
+ annotations: {
1413
+ readOnlyHint: false,
1414
+ destructiveHint: true,
1415
+ openWorldHint: true
1416
+ }
1417
+ }, ({ programId, pwId, confirm }, extra) => attempt(async () => {
1418
+ if (!await confirmGate(server, extra.requestId, `Delete session ${pwId}? This removes it from the live calendar and is hard to undo.`, confirm)) return errorResult(NOT_CONFIRMED);
1419
+ await removeSession(ctx.client, programId, pwId);
1420
+ return jsonResult({ removed: pwId });
1421
+ }));
1422
+ }
1423
+ //#endregion
1424
+ //#region ../core/src/tools/messaging.ts
1425
+ function registerReads(server, ctx) {
1426
+ server.registerTool("messaging_conversations", {
1427
+ title: "List conversations (live)",
1428
+ description: "List chat streams (id, kind, title) live from the API; no setup needed. Use the id to read/draft/send.",
1429
+ inputSchema: {},
1430
+ annotations: READ
1431
+ }, () => attempt(async () => {
1432
+ return jsonResult((await fetchStreams(ctx.client)).map(({ stream, kind }) => ({
1433
+ id: stream.id,
1434
+ kind,
1435
+ title: stream.title ?? "",
1436
+ teamId: stream.teamId,
1437
+ userId: stream.userId
1438
+ })));
1439
+ }));
1440
+ server.registerTool("messaging_read", {
1441
+ title: "Read messages (live)",
1442
+ description: "Recent comments in a stream, live from the API (the stream is fetched whole, then trimmed to `limit`).",
1443
+ inputSchema: {
1444
+ streamId: idParam,
1445
+ limit: z.number().int().positive().max(200).optional()
1446
+ },
1447
+ annotations: READ
1448
+ }, ({ streamId, limit }) => attempt(async () => jsonResult(await readLive(ctx.client, toId(streamId), limit ?? 20))));
1449
+ server.registerTool("message_draft", {
1450
+ title: "Draft a message (preview only)",
1451
+ description: "Preview the exact payload and target WITHOUT sending. Always safe.",
1452
+ inputSchema: commentDraftSchema.shape,
1453
+ annotations: READ
1454
+ }, ({ streamId, text, replyTo }) => attempt(async () => {
1455
+ const id = toId(streamId);
1456
+ return jsonResult({
1457
+ draft: true,
1458
+ note: "NOT sent. This is a preview. Run message_send to deliver it.",
1459
+ would_POST: `/v5/messaging/streams/${id}/comments`,
1460
+ payload: buildCommentPayload(id, text, replyTo === void 0 ? null : toId(replyTo))
1461
+ });
1462
+ }));
1463
+ }
1464
+ function registerWrites(server, ctx) {
1465
+ server.registerTool("message_send", {
1466
+ title: "Send a message",
1467
+ description: "Send a chat message — ATHLETE-FACING and immediate (no draft state on the server). Requires confirmation (elicitation, or confirm:true). Prefer message_draft first.",
1468
+ inputSchema: {
1469
+ ...commentDraftSchema.shape,
1470
+ confirm: z.boolean().optional()
1471
+ },
1472
+ annotations: DESTRUCTIVE
1473
+ }, ({ streamId, text, replyTo, confirm }, extra) => attempt(async () => {
1474
+ const id = toId(streamId);
1475
+ if (!await confirmGate(server, extra.requestId, `Send this message to stream ${id}? It is athlete-facing and immediate.`, confirm)) return errorResult(NOT_CONFIRMED);
1476
+ return jsonResult({
1477
+ sent: true,
1478
+ comment: await sendComment(ctx.client, id, text, replyTo === void 0 ? null : toId(replyTo))
1479
+ });
1480
+ }));
1481
+ server.registerTool("message_delete", {
1482
+ title: "Delete a message",
1483
+ description: "Soft-delete a chat message on the live account. Requires confirmation.",
1484
+ inputSchema: {
1485
+ streamId: idParam,
1486
+ commentId: idParam,
1487
+ confirm: z.boolean().optional()
1488
+ },
1489
+ annotations: DESTRUCTIVE
1490
+ }, ({ streamId, commentId, confirm }, extra) => attempt(async () => {
1491
+ if (!await confirmGate(server, extra.requestId, `Delete comment ${toId(commentId)} from stream ${toId(streamId)}? Acts on the live account.`, confirm)) return errorResult(NOT_CONFIRMED);
1492
+ return jsonResult({
1493
+ deleted: true,
1494
+ response: await deleteComment(ctx.client, toId(streamId), toId(commentId))
1495
+ });
1496
+ }));
1497
+ }
1498
+ /** Live messaging: list/read conversations, draft a message, and the gated send/delete. */
1499
+ function registerMessagingTools(server, ctx) {
1500
+ registerReads(server, ctx);
1501
+ registerWrites(server, ctx);
1502
+ }
1503
+ //#endregion
1504
+ //#region ../js/src/node.ts
1505
+ /** Default exercise-cache path, overridable with TRAINHEROIC_CACHE_FILE. */
1506
+ function defaultCachePath() {
1507
+ return process.env.TRAINHEROIC_CACHE_FILE ?? join(homedir(), ".trainheroic", "library.json");
1508
+ }
1509
+ /** Persists the exercise library to a JSON file. */
1510
+ var JsonFileLibraryCache = class {
1511
+ #path;
1512
+ constructor(path = defaultCachePath()) {
1513
+ this.#path = path;
1514
+ }
1515
+ async load() {
1516
+ try {
1517
+ const parsed = JSON.parse(await readFile(this.#path, "utf8"));
1518
+ if (typeof parsed.fetchedAt === "number" && Array.isArray(parsed.exercises)) return parsed;
1519
+ } catch {}
1520
+ return null;
1521
+ }
1522
+ async save(snapshot) {
1523
+ await mkdir(dirname(this.#path), { recursive: true });
1524
+ await writeFile(this.#path, JSON.stringify(snapshot), { mode: 384 });
1525
+ }
1526
+ };
1527
+ //#endregion
1528
+ //#region src/server.ts
1529
+ async function main() {
1530
+ const email = process.env.TRAINHEROIC_EMAIL;
1531
+ const password = process.env.TRAINHEROIC_PASSWORD;
1532
+ if (!email || !password) {
1533
+ process.stderr.write("Set TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD in the environment.\n");
1534
+ process.exit(1);
1535
+ }
1536
+ const client = new TrainHeroicClient(email, password);
1537
+ const ctx = {
1538
+ client,
1539
+ index: new ExerciseLibrary(client, new JsonFileLibraryCache())
1540
+ };
1541
+ const server = new McpServer({
1542
+ name: "trainheroic-local",
1543
+ version: "0.1.0"
1544
+ });
1545
+ registerReadTools(server, ctx);
1546
+ registerRawTools(server, ctx);
1547
+ registerExerciseTools(server, ctx);
1548
+ registerWorkoutTools(server, ctx);
1549
+ registerMessagingTools(server, ctx);
1550
+ await server.connect(new StdioServerTransport());
1551
+ }
1552
+ await main();
1553
+ //#endregion
1554
+ export {};