@trainheroic-unofficial/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -63,6 +63,22 @@ declare function confirmGate(server: McpServer, requestId: string | number | und
63
63
  /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
64
64
  declare function registerReadTools(server: McpServer, ctx: ToolContext): void;
65
65
  //#endregion
66
+ //#region src/tools/athlete-training.d.ts
67
+ /**
68
+ * Athlete tools need only the client (no exercise-library index), so they take a narrower
69
+ * context than the coach tools. A full `ToolContext` also satisfies this, so the same ctx
70
+ * object can be passed.
71
+ */
72
+ type AthleteContext = {
73
+ client: TrainHeroicClient;
74
+ };
75
+ /**
76
+ * Live tools over the logged-in user's own training (history, scheduled/completed workouts,
77
+ * PRs, working maxes), plus a gated set-logging write. The athlete user id is
78
+ * resolved once from /user/simple and reused across tools.
79
+ */
80
+ declare function registerAthleteTrainingTools(server: McpServer, ctx: AthleteContext): void;
81
+ //#endregion
66
82
  //#region src/tools/athletes.d.ts
67
83
  /**
68
84
  * Athlete management. TrainHeroic has no "create athlete" primitive: a coach invites a
@@ -100,4 +116,4 @@ declare function registerWorkoutTools(server: McpServer, ctx: ToolContext): void
100
116
  /** Live messaging: list/read conversations, draft a message, and the gated send/delete. */
101
117
  declare function registerMessagingTools(server: McpServer, ctx: ToolContext): void;
102
118
  //#endregion
103
- export { BudgetHint, DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, ToolContext, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerAnalyticsTools, registerAthleteTools, registerExerciseTools, registerMessagingTools, registerReadTools, registerTeamTools, registerWorkoutTools, resultBudget, toId };
119
+ export { AthleteContext, BudgetHint, DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, ToolContext, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerAnalyticsTools, registerAthleteTools, registerAthleteTrainingTools, registerExerciseTools, registerMessagingTools, registerReadTools, registerTeamTools, registerWorkoutTools, resultBudget, toId };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { blockSpecSchema, commentDraftSchema, exerciseCreateSchema, idArgSchema, parseWorkoutDate } from "@trainheroic-unofficial/dto";
1
+ import { blockSpecSchema, commentDraftSchema, dateString, exerciseCreateSchema, idArgSchema, logSetArgsSchema, parseWorkoutDate } from "@trainheroic-unofficial/dto";
2
2
  import { z } from "zod";
3
- import { buildCommentPayload, buildSession, collectAdvisories, deleteComment, fetchStreams, publishSession, readLive, readSession, removeSession, sendComment } from "@trainheroic-unofficial/js";
3
+ import { buildCommentPayload, buildSession, coerceInt, collectAdvisories, deleteComment, exerciseUnits, fetchAthletePrefs, fetchAthleteProfileSummary, fetchAthleteUser, fetchAthleteWorkouts, fetchExerciseHistoryDetail, fetchExerciseHistoryList, fetchExerciseStats, fetchLeaderboard, fetchPersonalRecords, fetchStreams, fetchWorkingMaxes, isRecord, logAthleteSet, presentAthleteWorkouts, presentExerciseHistory, publishSession, readLive, readSession, removeSession, searchExerciseHistory, sendComment } from "@trainheroic-unofficial/js";
4
4
  //#region src/context.ts
5
5
  /** A tool argument that accepts a numeric id as a number or a string of digits. */
6
6
  const idParam = idArgSchema;
@@ -300,42 +300,175 @@ function registerEntityReads(server, ctx) {
300
300
  inputSchema: { programId: idParam },
301
301
  annotations: READ
302
302
  }, ({ 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."));
303
- server.registerTool("coach_activity_feed", {
304
- title: "Coach activity feed",
305
- description: "Recent athlete activity across the roster (GET /v5/coaches/activityFeed): completed workouts, PRs, and posts. Paginate with page/pageSize.",
303
+ }
304
+ /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
305
+ function registerReadTools(server, ctx) {
306
+ registerRosterReads(server, ctx);
307
+ registerEntityReads(server, ctx);
308
+ }
309
+ //#endregion
310
+ //#region src/tools/athlete-training.ts
311
+ /** Identity, profile, prefs, working maxes, leaderboard. */
312
+ function registerProfileTools(server, ctx, whoami, userId) {
313
+ server.registerTool("athlete_whoami", {
314
+ title: "Who am I (athlete)",
315
+ description: "The logged-in account's identity (id, name, roles) from /user/simple.",
316
+ inputSchema: {},
317
+ annotations: READ
318
+ }, () => attempt(async () => jsonResult(await whoami())));
319
+ server.registerTool("athlete_profile", {
320
+ title: "Athlete profile + lifetime totals",
321
+ description: "Lifetime training totals (reps, volume, sessions, first/last logged) plus the profile (name, units, dob). Set useMetric for kg/metric totals.",
322
+ inputSchema: { useMetric: z.boolean().optional() },
323
+ annotations: READ
324
+ }, ({ useMetric }) => attempt(async () => {
325
+ const id = await userId();
326
+ const [summary, user] = await Promise.all([fetchAthleteProfileSummary(ctx.client, id, useMetric ?? false), fetchAthleteUser(ctx.client, id)]);
327
+ return jsonResult({
328
+ summary,
329
+ user
330
+ });
331
+ }));
332
+ server.registerTool("athlete_prefs", {
333
+ title: "Athlete preferences",
334
+ description: "Notification and display preference flags for the athlete account.",
335
+ inputSchema: {},
336
+ annotations: READ
337
+ }, () => attempt(async () => jsonResult(await fetchAthletePrefs(ctx.client))));
338
+ server.registerTool("athlete_working_maxes", {
339
+ title: "Working maxes",
340
+ description: "The athlete's working max per exercise (drives % prescriptions).",
341
+ inputSchema: {},
342
+ annotations: READ
343
+ }, () => attempt(async () => jsonResult(await fetchWorkingMaxes(ctx.client))));
344
+ server.registerTool("athlete_leaderboard", {
345
+ title: "Benchmark leaderboard",
346
+ description: "Leaderboard for a benchmark/test workout by its workout id.",
306
347
  inputSchema: {
348
+ workoutId: idParam,
307
349
  page: z.number().int().positive().optional(),
308
- pageSize: z.number().int().positive().optional()
350
+ pageSize: z.number().int().positive().max(200).optional(),
351
+ gender: z.number().int().optional()
309
352
  },
310
353
  annotations: READ
311
- }, ({ page, pageSize }) => {
312
- const qs = new URLSearchParams();
313
- if (page !== void 0) qs.set("page", String(page));
314
- if (pageSize !== void 0) qs.set("pageSize", String(pageSize));
315
- const query = qs.toString();
316
- return apiCall(ctx, "GET", `/v5/coaches/activityFeed${query ? `?${query}` : ""}`);
317
- });
354
+ }, ({ workoutId, page, pageSize, gender }) => attempt(async () => {
355
+ const opts = {};
356
+ if (page !== void 0) opts.page = page;
357
+ if (pageSize !== void 0) opts.pageSize = pageSize;
358
+ if (gender !== void 0) opts.gender = gender;
359
+ return jsonResult(await fetchLeaderboard(ctx.client, toId(workoutId), opts));
360
+ }));
361
+ }
362
+ /** Workouts, exercise catalog, per-exercise history/PRs/stats. */
363
+ function registerExerciseTools$1(server, ctx, userId) {
318
364
  server.registerTool("athlete_workouts", {
319
- title: "Athlete workout history",
320
- description: "One athlete's workouts with their survey/readiness data (GET /v5/coaches/athletes/{athleteId}/workouts). Bound it with startDate/endDate (YYYY-MM-DD) to keep the response small.",
365
+ title: "Workouts in a date range",
366
+ description: "Scheduled + completed workouts in an inclusive YYYY-MM-DD window, flattened to blocks/exercises with per-set prescriptions and positional units. Set raw:true for the untouched API objects. Narrow the window if the result is truncated.",
321
367
  inputSchema: {
322
- athleteId: idParam,
323
- startDate: z.string().optional(),
324
- endDate: z.string().optional()
368
+ startDate: dateString,
369
+ endDate: dateString,
370
+ raw: z.boolean().optional()
325
371
  },
326
372
  annotations: READ
327
- }, ({ athleteId, startDate, endDate }) => {
328
- const qs = new URLSearchParams();
329
- if (startDate !== void 0) qs.set("startDate", startDate);
330
- if (endDate !== void 0) qs.set("endDate", endDate);
331
- const query = qs.toString();
332
- return apiCall(ctx, "GET", `/v5/coaches/athletes/${enc(athleteId)}/workouts${query ? `?${query}` : ""}`, void 0, "Narrow the date range to shrink this if it is truncated.");
333
- });
373
+ }, ({ startDate, endDate, raw }) => attempt(async () => {
374
+ const workouts = await fetchAthleteWorkouts(ctx.client, startDate, endDate);
375
+ return jsonResult(raw === true ? workouts : presentAthleteWorkouts(workouts), { hint: "Narrow startDate/endDate to shrink this result." });
376
+ }));
377
+ server.registerTool("athlete_exercises", {
378
+ title: "Search logged exercises",
379
+ description: "The exercises the athlete has logged (id + title + positional units). Pass q to free-text search by name; use the returned id with athlete_exercise_history / _stats.",
380
+ inputSchema: {
381
+ q: z.string().optional(),
382
+ limit: z.number().int().positive().max(200).optional()
383
+ },
384
+ annotations: READ
385
+ }, ({ q, limit }) => attempt(async () => {
386
+ return jsonResult((q !== void 0 && q.trim() !== "" ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
387
+ id: r.id,
388
+ title: r.title,
389
+ isCircuit: r.isCircuit ?? false,
390
+ units: exerciseUnits(r.param1Type, r.param2Type)
391
+ })), { hint: "Pass q to search by name, or limit to cap the list." });
392
+ }));
393
+ server.registerTool("athlete_exercise_history", {
394
+ title: "Exercise history + PRs",
395
+ description: "Per-exercise PRs and the dated session time-series (sets performed, estimated 1RM). Set raw:true for the untouched API object. Get the exercise id from athlete_exercises.",
396
+ inputSchema: {
397
+ exerciseId: idParam,
398
+ raw: z.boolean().optional()
399
+ },
400
+ annotations: READ
401
+ }, ({ exerciseId, raw }) => attempt(async () => {
402
+ const detail = await fetchExerciseHistoryDetail(ctx.client, toId(exerciseId), await userId());
403
+ return jsonResult(raw === true ? detail : presentExerciseHistory(detail));
404
+ }));
405
+ server.registerTool("athlete_personal_records", {
406
+ title: "Exercise personal records",
407
+ description: "Personal records for an exercise (reps/weight, strength-standard filters).",
408
+ inputSchema: { exerciseId: idParam },
409
+ annotations: READ
410
+ }, ({ exerciseId }) => attempt(async () => jsonResult(await fetchPersonalRecords(ctx.client, toId(exerciseId)))));
411
+ server.registerTool("athlete_exercise_stats", {
412
+ title: "Exercise stats (last performance + PR)",
413
+ description: "Last performance and PR for an exercise as of a date (YYYY-MM-DD, required by the API).",
414
+ inputSchema: {
415
+ exerciseId: idParam,
416
+ date: dateString
417
+ },
418
+ annotations: READ
419
+ }, ({ exerciseId, date }) => attempt(async () => jsonResult(await fetchExerciseStats(ctx.client, toId(exerciseId), await userId(), date))));
334
420
  }
335
- /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
336
- function registerReadTools(server, ctx) {
337
- registerRosterReads(server, ctx);
338
- registerEntityReads(server, ctx);
421
+ /** The gated set-logging write. */
422
+ function registerLogTool(server, ctx) {
423
+ server.registerTool("athlete_log_set", {
424
+ title: "Log completed set results",
425
+ description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day, marking the set completed. Writes to the athlete's (coach-visible) training log and shows in exercise history. Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_workouts (raw:true). Requires confirmation (elicitation or confirm:true).",
426
+ inputSchema: {
427
+ ...logSetArgsSchema.shape,
428
+ confirm: z.boolean().optional()
429
+ },
430
+ annotations: DESTRUCTIVE
431
+ }, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
432
+ if (!await confirmGate(server, extra.requestId, `Log results to saved workout set ${toId(savedWorkoutSetId)} on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
433
+ const mapped = results.map((r) => ({
434
+ savedWorkoutSetExerciseId: toId(r.savedWorkoutSetExerciseId),
435
+ sets: r.sets.map((s) => {
436
+ const slot = {};
437
+ if (s.param1 !== void 0) slot.param1 = s.param1;
438
+ if (s.param2 !== void 0) slot.param2 = s.param2;
439
+ return slot;
440
+ })
441
+ }));
442
+ return jsonResult(await logAthleteSet(ctx.client, {
443
+ date,
444
+ savedWorkoutSetId: toId(savedWorkoutSetId),
445
+ results: mapped
446
+ }));
447
+ }));
448
+ }
449
+ /**
450
+ * Live tools over the logged-in user's own training (history, scheduled/completed workouts,
451
+ * PRs, working maxes), plus a gated set-logging write. The athlete user id is
452
+ * resolved once from /user/simple and reused across tools.
453
+ */
454
+ function registerAthleteTrainingTools(server, ctx) {
455
+ let whoamiCache = null;
456
+ const whoami = async () => {
457
+ if (whoamiCache === null) {
458
+ const res = await ctx.client.request("GET", "/user/simple");
459
+ if (!res.ok || !isRecord(res.data)) throw new Error("Could not load /user/simple.");
460
+ whoamiCache = res.data;
461
+ }
462
+ return whoamiCache;
463
+ };
464
+ const userId = async () => {
465
+ const id = coerceInt((await whoami()).id);
466
+ if (id === null || id <= 0) throw new Error("Could not resolve athlete user id from /user/simple.");
467
+ return id;
468
+ };
469
+ registerProfileTools(server, ctx, whoami, userId);
470
+ registerExerciseTools$1(server, ctx, userId);
471
+ registerLogTool(server, ctx);
339
472
  }
340
473
  //#endregion
341
474
  //#region src/tools/athletes.ts
@@ -486,7 +619,6 @@ const METRIC_KEYS = [
486
619
  "readiness-athlete",
487
620
  "lift-1rm-history",
488
621
  "training-summary-athlete",
489
- "training-summary-team",
490
622
  "compliance-team",
491
623
  "lift-progress-team",
492
624
  "working-max-history"
@@ -495,58 +627,80 @@ const METRICS = {
495
627
  "readiness-team": {
496
628
  path: "/v5/analytics/readiness/teams",
497
629
  scope: "team",
498
- fields: ["date"]
630
+ inputs: ["date"],
631
+ requires: ["date"]
499
632
  },
500
633
  "readiness-athlete": {
501
634
  path: "/v5/analytics/readiness/users",
502
635
  scope: "user",
503
- fields: ["date"]
636
+ inputs: ["dateStart", "dateEnd"],
637
+ requires: ["dateStart", "dateEnd"]
504
638
  },
505
639
  "lift-1rm-history": {
506
640
  path: "/v5/analytics/lift-one-rep-max-history/users",
507
641
  scope: "user",
508
- fields: [
509
- "date_start",
510
- "date_end",
511
- "exercise_id",
512
- "use_metric"
642
+ inputs: [
643
+ "dateStart",
644
+ "dateEnd",
645
+ "exerciseId",
646
+ "useMetric"
647
+ ],
648
+ requires: [
649
+ "dateStart",
650
+ "dateEnd",
651
+ "exerciseId"
513
652
  ]
514
653
  },
515
654
  "training-summary-athlete": {
516
655
  path: "/v5/analytics/training-summary/users",
517
656
  scope: "user",
518
- fields: ["date_start", "date_end"]
519
- },
520
- "training-summary-team": {
521
- path: "/v5/analytics/training-summary/teams",
522
- scope: "team",
523
- fields: ["date_start", "date_end"]
657
+ inputs: ["dateStart", "dateEnd"],
658
+ requires: ["dateStart", "dateEnd"]
524
659
  },
525
660
  "compliance-team": {
526
661
  path: "/v5/analytics/compliance",
527
662
  scope: "team",
528
- fields: ["date_start", "date_end"]
663
+ inputs: ["dateStart", "dateEnd"],
664
+ requires: ["dateStart", "dateEnd"]
529
665
  },
530
666
  "lift-progress-team": {
531
667
  path: "/v5/analytics/lift-progress/teams",
532
668
  scope: "team",
533
- fields: [
534
- "exercise_id",
535
- "date_start",
536
- "date_end"
669
+ inputs: [
670
+ "exerciseId",
671
+ "dateStart",
672
+ "dateEnd"
673
+ ],
674
+ requires: [
675
+ "exerciseId",
676
+ "dateStart",
677
+ "dateEnd"
537
678
  ]
538
679
  },
539
680
  "working-max-history": {
540
681
  path: "/v5/analytics/working-max-history/users",
541
682
  scope: "user",
542
- fields: ["date_start", "date_end"]
683
+ inputs: [
684
+ "exerciseId",
685
+ "dateStart",
686
+ "dateEnd",
687
+ "useMetric"
688
+ ],
689
+ requires: ["exerciseId"]
543
690
  }
544
691
  };
692
+ const BODY_KEY = {
693
+ date: "date",
694
+ dateStart: "date_start",
695
+ dateEnd: "date_end",
696
+ exerciseId: "exercise_id",
697
+ useMetric: "use_metric"
698
+ };
545
699
  /** Analytics report pulls. Read-only despite the POST verb, so ungated. */
546
700
  function registerAnalyticsTools(server, ctx) {
547
701
  server.registerTool("analytics_query", {
548
702
  title: "Pull an analytics report",
549
- description: "Read a TrainHeroic analytics report. `metric` picks the report (run analytics_categories for the catalog). Team metrics need teamId; athlete metrics need userIds. Dates are YYYY-MM-DD: readiness takes a single `date`, history/summary metrics take dateStart/dateEnd. lift-1rm-history and lift-progress-team also take exerciseId.",
703
+ description: "Read a TrainHeroic analytics report. `metric` picks the report (run analytics_categories for the catalog). Team metrics (readiness-team, compliance-team, lift-progress-team) need teamId; athlete metrics need userIds. readiness-team takes a single `date`; every other metric takes dateStart/dateEnd. lift-1rm-history, lift-progress-team, and working-max-history also need exerciseId. All dates are YYYY-MM-DD.",
550
704
  inputSchema: {
551
705
  metric: z.enum(METRIC_KEYS),
552
706
  teamId: idParam.optional(),
@@ -560,6 +714,13 @@ function registerAnalyticsTools(server, ctx) {
560
714
  annotations: READ
561
715
  }, async ({ metric, teamId, userIds, exerciseId, date, dateStart, dateEnd, useMetric }) => {
562
716
  const spec = METRICS[metric];
717
+ const inputs = {
718
+ date,
719
+ dateStart,
720
+ dateEnd,
721
+ exerciseId,
722
+ useMetric
723
+ };
563
724
  const body = {};
564
725
  if (spec.scope === "team") {
565
726
  if (teamId === void 0) return errorResult(`${metric} needs teamId.`);
@@ -568,14 +729,13 @@ function registerAnalyticsTools(server, ctx) {
568
729
  if (userIds === void 0 || userIds.length === 0) return errorResult(`${metric} needs userIds (one or more athlete ids).`);
569
730
  body.user_ids = userIds.map((u) => String(toId(u)));
570
731
  }
571
- const provided = {
572
- date,
573
- date_start: dateStart,
574
- date_end: dateEnd,
575
- exercise_id: exerciseId === void 0 ? void 0 : String(toId(exerciseId)),
576
- use_metric: useMetric
577
- };
578
- for (const field of spec.fields) if (provided[field] !== void 0) body[field] = provided[field];
732
+ const missing = spec.requires.filter((k) => inputs[k] === void 0);
733
+ if (missing.length > 0) return errorResult(`${metric} also needs: ${missing.join(", ")}.`);
734
+ for (const k of spec.inputs) {
735
+ const v = inputs[k];
736
+ if (v === void 0) continue;
737
+ body[BODY_KEY[k]] = k === "exerciseId" ? String(toId(v)) : v;
738
+ }
579
739
  return apiCall(ctx, "POST", spec.path, { body }, "Narrow with a tighter date range or fewer athletes.");
580
740
  });
581
741
  }
@@ -891,4 +1051,4 @@ function registerMessagingTools(server, ctx) {
891
1051
  registerWrites(server, ctx);
892
1052
  }
893
1053
  //#endregion
894
- export { DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerAnalyticsTools, registerAthleteTools, registerExerciseTools, registerMessagingTools, registerReadTools, registerTeamTools, registerWorkoutTools, resultBudget, toId };
1054
+ export { DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerAnalyticsTools, registerAthleteTools, registerAthleteTrainingTools, registerExerciseTools, registerMessagingTools, registerReadTools, registerTeamTools, registerWorkoutTools, resultBudget, toId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,8 +25,8 @@
25
25
  "@cfworker/json-schema": "^4.1.1",
26
26
  "@modelcontextprotocol/sdk": "^1.29.0",
27
27
  "zod": "^4.4.3",
28
- "@trainheroic-unofficial/dto": "0.3.0",
29
- "@trainheroic-unofficial/js": "0.3.0"
28
+ "@trainheroic-unofficial/dto": "0.4.0",
29
+ "@trainheroic-unofficial/js": "0.4.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^26.0.0",