@trainheroic-unofficial/core 0.2.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/README.md CHANGED
@@ -16,7 +16,9 @@ functions:
16
16
  ```ts
17
17
  import {
18
18
  registerReadTools,
19
- registerRawTools,
19
+ registerAthleteTools,
20
+ registerTeamTools,
21
+ registerAnalyticsTools,
20
22
  registerExerciseTools,
21
23
  registerWorkoutTools,
22
24
  registerMessagingTools,
@@ -36,15 +38,18 @@ change.
36
38
  ## What the tools cover
37
39
 
38
40
  The tools group into coach reads (profile, athletes, teams, programs, notifications,
39
- analytics), exercise library operations (resolve, search, get, sync, create, forget,
40
- stats), the workout lifecycle (build a draft, read it back, publish, remove), messaging
41
- (list, read, draft, send, delete), and a `th_request` escape hatch for any endpoint the
42
- dedicated tools do not cover.
41
+ analytics catalog), athlete management (invite, archive, restore), team management (create,
42
+ rename, delete, join codes), analytics report pulls (`analytics_query`: readiness, 1RM and
43
+ working-max history, training summary, compliance, lift progress), exercise library
44
+ operations (resolve, search, get, sync, create,
45
+ forget, stats), the workout/session lifecycle (build a draft, read it back, publish,
46
+ unpublish, copy, save as template, remove), and messaging (list, read, draft, send, delete).
47
+ There is no raw-request escape hatch; every endpoint reaches the model through a typed tool.
43
48
 
44
49
  Tools return their result in-band. A failure comes back as an error result the model can
45
50
  read and correct, not a thrown exception. Reads are marked read-only. Athlete-facing or
46
- destructive actions (publish, remove, send, delete, and non-GET `th_request`) pass through a
47
- confirmation gate before they run.
51
+ destructive actions (publish, unpublish, remove, send, delete, archive, team/code delete)
52
+ pass through a confirmation gate before they run.
48
53
 
49
54
  The D1-backed warehouse sync tools are not here; they live in the `cloudflare` package
50
55
  because they depend on its storage.
package/dist/index.d.mts CHANGED
@@ -63,13 +63,42 @@ 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/raw.d.ts
66
+ //#region src/tools/athlete-training.d.ts
67
67
  /**
68
- * Escape hatch covering every endpoint without a dedicated tool (e.g. the analytics
69
- * POSTs). GET is ungated; mutating methods go through the same confirmation gate as
70
- * the dedicated destructive tools so this cannot be used to bypass it.
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
71
  */
72
- declare function registerRawTools(server: McpServer, ctx: ToolContext): void;
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
82
+ //#region src/tools/athletes.d.ts
83
+ /**
84
+ * Athlete management. TrainHeroic has no "create athlete" primitive: a coach invites a
85
+ * person by email to a team, and the athlete record only exists once they accept and set
86
+ * their own name. So the create path is the two-step invite from the API reference:
87
+ * validate the address (POST /v5/emails/validate), then inviteToTeam
88
+ * (POST /v5/athletes/inviteToTeam). Both act on the live account, so the invite is gated.
89
+ */
90
+ declare function registerAthleteTools(server: McpServer, ctx: ToolContext): void;
91
+ //#endregion
92
+ //#region src/tools/teams.d.ts
93
+ /**
94
+ * Team write tools. The team reads (list_teams, get_team, list_team_codes) live in
95
+ * reads.ts; this module covers create/rename/delete plus the join-code lifecycle.
96
+ */
97
+ declare function registerTeamTools(server: McpServer, ctx: ToolContext): void;
98
+ //#endregion
99
+ //#region src/tools/analytics.d.ts
100
+ /** Analytics report pulls. Read-only despite the POST verb, so ungated. */
101
+ declare function registerAnalyticsTools(server: McpServer, ctx: ToolContext): void;
73
102
  //#endregion
74
103
  //#region src/tools/exercises.d.ts
75
104
  /**
@@ -80,11 +109,11 @@ declare function registerRawTools(server: McpServer, ctx: ToolContext): void;
80
109
  declare function registerExerciseTools(server: McpServer, ctx: ToolContext): void;
81
110
  //#endregion
82
111
  //#region src/tools/workout.d.ts
83
- /** Workout building, read-back, publishing, and removal. */
112
+ /** Workout building, read-back, publishing, and the session calendar lifecycle. */
84
113
  declare function registerWorkoutTools(server: McpServer, ctx: ToolContext): void;
85
114
  //#endregion
86
115
  //#region src/tools/messaging.d.ts
87
116
  /** Live messaging: list/read conversations, draft a message, and the gated send/delete. */
88
117
  declare function registerMessagingTools(server: McpServer, ctx: ToolContext): void;
89
118
  //#endregion
90
- export { BudgetHint, DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, ToolContext, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerExerciseTools, registerMessagingTools, registerRawTools, registerReadTools, 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;
@@ -221,12 +221,12 @@ const SIMPLE_GETS = [
221
221
  {
222
222
  name: "analytics_categories",
223
223
  title: "Analytics categories",
224
- description: "Lists available analytics types. Pull the data via th_request POST /v5/analytics/*.",
224
+ description: "Lists available analytics types. Pull a report with analytics_query (pick the matching metric).",
225
225
  path: "/v5/analytics"
226
226
  }
227
227
  ];
228
- /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
229
- function registerReadTools(server, ctx) {
228
+ /** Roster-level reads: the fixed GETs plus the filterable athlete and team lists. */
229
+ function registerRosterReads(server, ctx) {
230
230
  for (const t of SIMPLE_GETS) server.registerTool(t.name, {
231
231
  title: t.title,
232
232
  description: t.description,
@@ -279,6 +279,9 @@ function registerReadTools(server, ctx) {
279
279
  const query = qs.toString();
280
280
  return apiCall(ctx, "GET", `/1.0/coach/teams${query ? `?${query}` : ""}`);
281
281
  });
282
+ }
283
+ /** Reads scoped to a single entity (team, program, athlete) plus the activity feed. */
284
+ function registerEntityReads(server, ctx) {
282
285
  server.registerTool("get_team", {
283
286
  title: "Get team",
284
287
  description: "Full team object by team id.",
@@ -298,38 +301,442 @@ function registerReadTools(server, ctx) {
298
301
  annotations: READ
299
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."));
300
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
+ }
301
309
  //#endregion
302
- //#region src/tools/raw.ts
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.",
347
+ inputSchema: {
348
+ workoutId: idParam,
349
+ page: z.number().int().positive().optional(),
350
+ pageSize: z.number().int().positive().max(200).optional(),
351
+ gender: z.number().int().optional()
352
+ },
353
+ annotations: READ
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) {
364
+ server.registerTool("athlete_workouts", {
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.",
367
+ inputSchema: {
368
+ startDate: dateString,
369
+ endDate: dateString,
370
+ raw: z.boolean().optional()
371
+ },
372
+ annotations: READ
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))));
420
+ }
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
+ }
303
449
  /**
304
- * Escape hatch covering every endpoint without a dedicated tool (e.g. the analytics
305
- * POSTs). GET is ungated; mutating methods go through the same confirmation gate as
306
- * the dedicated destructive tools so this cannot be used to bypass it.
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.
307
453
  */
308
- function registerRawTools(server, ctx) {
309
- server.registerTool("th_request", {
310
- title: "Raw TrainHeroic request",
311
- 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.",
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);
472
+ }
473
+ //#endregion
474
+ //#region src/tools/athletes.ts
475
+ const DEFAULT_INVITE_MESSAGE = "Follow these steps and you'll be set up and ready to go!";
476
+ /** Normalize one-or-many emails into a deduped, trimmed list. */
477
+ function asEmailList(emails) {
478
+ return [...new Set((Array.isArray(emails) ? emails : [emails]).map((e) => e.trim()).filter((e) => e.length > 0))];
479
+ }
480
+ /**
481
+ * Athlete management. TrainHeroic has no "create athlete" primitive: a coach invites a
482
+ * person by email to a team, and the athlete record only exists once they accept and set
483
+ * their own name. So the create path is the two-step invite from the API reference:
484
+ * validate the address (POST /v5/emails/validate), then inviteToTeam
485
+ * (POST /v5/athletes/inviteToTeam). Both act on the live account, so the invite is gated.
486
+ */
487
+ function registerAthleteTools(server, ctx) {
488
+ server.registerTool("athlete_invite", {
489
+ title: "Invite an athlete",
490
+ description: "Add athletes by emailing them a TrainHeroic team invitation — this is how you 'create' an athlete; there is no other create path, and no name is collected here (the athlete sets their own on accept). Targets a team by id, so list_teams first if you need one. Validates each address, then sends the invite. ATHLETE-FACING and immediate; requires confirmation (elicitation, or confirm:true).",
312
491
  inputSchema: {
313
- method: z.enum([
314
- "GET",
315
- "POST",
316
- "PUT",
317
- "DELETE"
318
- ]),
319
- path: z.string().min(1),
320
- body: z.unknown().optional(),
321
- base: z.enum(["coach", "apis"]).optional(),
492
+ teamId: idParam,
493
+ emails: z.union([z.string(), z.array(z.string()).min(1)]),
494
+ message: z.string().optional(),
322
495
  confirm: z.boolean().optional()
323
496
  },
324
497
  annotations: DESTRUCTIVE
325
- }, async ({ method, path, body, base, confirm }, extra) => {
326
- if (method !== "GET") {
327
- if (!await confirmGate(server, extra.requestId, `Run ${method} ${path} against the live TrainHeroic account?`, confirm)) return errorResult(NOT_CONFIRMED);
498
+ }, ({ teamId, emails, message, confirm }, extra) => attempt(async () => {
499
+ const list = asEmailList(emails);
500
+ if (list.length === 0) return errorResult("Provide at least one email address to invite.");
501
+ const id = toId(teamId);
502
+ if (!await confirmGate(server, extra.requestId, `Invite ${list.join(", ")} to team ${id}? This emails them a real TrainHeroic invitation.`, confirm)) return errorResult(NOT_CONFIRMED);
503
+ const validation = await ctx.client.request("POST", "/v5/emails/validate", { body: { emails: list.join(",") } });
504
+ if (!validation.ok) {
505
+ const detail = typeof validation.data === "string" ? validation.data : JSON.stringify(validation.data);
506
+ return errorResult(`Email validation failed (HTTP ${validation.status}): ${detail}`);
507
+ }
508
+ const valid = Array.isArray(validation.data) ? validation.data : list;
509
+ if (valid.length === 0) return errorResult(`No valid addresses among: ${list.join(", ")}. They may be malformed or already on the team.`);
510
+ const invite = await ctx.client.request("POST", "/v5/athletes/inviteToTeam", { body: {
511
+ teamType: 0,
512
+ teamId: id,
513
+ orgId: null,
514
+ emails: valid,
515
+ message: message ?? DEFAULT_INVITE_MESSAGE
516
+ } });
517
+ if (!invite.ok) {
518
+ const detail = typeof invite.data === "string" ? invite.data : JSON.stringify(invite.data);
519
+ return errorResult(`Invite failed (HTTP ${invite.status}): ${detail}`);
520
+ }
521
+ return jsonResult({
522
+ invited: true,
523
+ teamId: id,
524
+ result: invite.data
525
+ });
526
+ }));
527
+ server.registerTool("athlete_archive", {
528
+ title: "Archive athletes",
529
+ description: "Remove one or more athletes from the active roster (PUT /v5/athletes/archive). Their data is preserved and they can be restored. Acts on the live account; requires confirmation (elicitation, or confirm:true).",
530
+ inputSchema: {
531
+ athleteIds: z.array(idParam).min(1),
532
+ confirm: z.boolean().optional()
533
+ },
534
+ annotations: DESTRUCTIVE
535
+ }, ({ athleteIds, confirm }, extra) => attempt(async () => {
536
+ const ids = athleteIds.map(toId);
537
+ if (!await confirmGate(server, extra.requestId, `Archive athlete(s) ${ids.join(", ")}? They leave the active roster (data is kept and restorable).`, confirm)) return errorResult(NOT_CONFIRMED);
538
+ return apiCall(ctx, "PUT", "/v5/athletes/archive", { body: { athleteIds: ids } });
539
+ }));
540
+ server.registerTool("athlete_restore", {
541
+ title: "Restore athletes",
542
+ description: "Restore previously archived athletes to the active roster (PUT /v5/athletes/restore).",
543
+ inputSchema: { athleteIds: z.array(idParam).min(1) },
544
+ annotations: {
545
+ readOnlyHint: false,
546
+ idempotentHint: true,
547
+ destructiveHint: false,
548
+ openWorldHint: true
328
549
  }
329
- const options = {};
330
- if (body !== void 0) options.body = body;
331
- if (base !== void 0) options.base = base;
332
- 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.");
550
+ }, ({ athleteIds }) => apiCall(ctx, "PUT", "/v5/athletes/restore", { body: { athleteIds: athleteIds.map(toId) } }));
551
+ }
552
+ //#endregion
553
+ //#region src/tools/teams.ts
554
+ const ADDITIVE = {
555
+ readOnlyHint: false,
556
+ destructiveHint: false,
557
+ openWorldHint: true
558
+ };
559
+ /**
560
+ * Team write tools. The team reads (list_teams, get_team, list_team_codes) live in
561
+ * reads.ts; this module covers create/rename/delete plus the join-code lifecycle.
562
+ */
563
+ function registerTeamTools(server, ctx) {
564
+ server.registerTool("team_create", {
565
+ title: "Create a team",
566
+ description: "Create a team (POST /1.0/coach/team/createWithTitleAndCode). Also creates the team's calendar/program. Returns the new team including its calendar id. Use the team id with athlete_invite.",
567
+ inputSchema: { title: z.string().min(1) },
568
+ annotations: ADDITIVE
569
+ }, ({ title }) => apiCall(ctx, "POST", "/1.0/coach/team/createWithTitleAndCode", { body: { title } }));
570
+ server.registerTool("team_update", {
571
+ title: "Rename a team",
572
+ description: "Update a team's settings, e.g. its title (PUT /v5/teams/{teamId}).",
573
+ inputSchema: {
574
+ teamId: idParam,
575
+ title: z.string().min(1)
576
+ },
577
+ annotations: ADDITIVE
578
+ }, ({ teamId, title }) => apiCall(ctx, "PUT", `/v5/teams/${toId(teamId)}`, { body: { title } }));
579
+ server.registerTool("team_delete", {
580
+ title: "Delete a team",
581
+ description: "Delete a team (DELETE /v5/teams/{teamId}). Removes the team and its calendar from the live account; hard to undo. Requires confirmation (elicitation, or confirm:true).",
582
+ inputSchema: {
583
+ teamId: idParam,
584
+ confirm: z.boolean().optional()
585
+ },
586
+ annotations: DESTRUCTIVE
587
+ }, ({ teamId, confirm }, extra) => attempt(async () => {
588
+ const id = toId(teamId);
589
+ if (!await confirmGate(server, extra.requestId, `Delete team ${id}? This removes the team and its calendar from the live account.`, confirm)) return errorResult(NOT_CONFIRMED);
590
+ return apiCall(ctx, "DELETE", `/v5/teams/${id}`);
591
+ }));
592
+ server.registerTool("team_code_create", {
593
+ title: "Create a team join code",
594
+ description: "Create an access code athletes use to self-join a team (POST /v5/teams/{teamId}/teamCodes). `type` defaults to 2, the standard join code.",
595
+ inputSchema: {
596
+ teamId: idParam,
597
+ type: z.number().int().optional()
598
+ },
599
+ annotations: ADDITIVE
600
+ }, ({ teamId, type }) => apiCall(ctx, "POST", `/v5/teams/${toId(teamId)}/teamCodes`, { body: { type: type ?? 2 } }));
601
+ server.registerTool("team_code_delete", {
602
+ title: "Delete a team join code",
603
+ description: "Delete a team access code by its id (DELETE /v5/teamCodes/{codeId}). Athletes can no longer use it to join. Requires confirmation (elicitation, or confirm:true).",
604
+ inputSchema: {
605
+ codeId: idParam,
606
+ confirm: z.boolean().optional()
607
+ },
608
+ annotations: DESTRUCTIVE
609
+ }, ({ codeId, confirm }, extra) => attempt(async () => {
610
+ const id = toId(codeId);
611
+ if (!await confirmGate(server, extra.requestId, `Delete team join code ${id}? Athletes can no longer use it to join.`, confirm)) return errorResult(NOT_CONFIRMED);
612
+ return apiCall(ctx, "DELETE", `/v5/teamCodes/${id}`);
613
+ }));
614
+ }
615
+ //#endregion
616
+ //#region src/tools/analytics.ts
617
+ const METRIC_KEYS = [
618
+ "readiness-team",
619
+ "readiness-athlete",
620
+ "lift-1rm-history",
621
+ "training-summary-athlete",
622
+ "compliance-team",
623
+ "lift-progress-team",
624
+ "working-max-history"
625
+ ];
626
+ const METRICS = {
627
+ "readiness-team": {
628
+ path: "/v5/analytics/readiness/teams",
629
+ scope: "team",
630
+ inputs: ["date"],
631
+ requires: ["date"]
632
+ },
633
+ "readiness-athlete": {
634
+ path: "/v5/analytics/readiness/users",
635
+ scope: "user",
636
+ inputs: ["dateStart", "dateEnd"],
637
+ requires: ["dateStart", "dateEnd"]
638
+ },
639
+ "lift-1rm-history": {
640
+ path: "/v5/analytics/lift-one-rep-max-history/users",
641
+ scope: "user",
642
+ inputs: [
643
+ "dateStart",
644
+ "dateEnd",
645
+ "exerciseId",
646
+ "useMetric"
647
+ ],
648
+ requires: [
649
+ "dateStart",
650
+ "dateEnd",
651
+ "exerciseId"
652
+ ]
653
+ },
654
+ "training-summary-athlete": {
655
+ path: "/v5/analytics/training-summary/users",
656
+ scope: "user",
657
+ inputs: ["dateStart", "dateEnd"],
658
+ requires: ["dateStart", "dateEnd"]
659
+ },
660
+ "compliance-team": {
661
+ path: "/v5/analytics/compliance",
662
+ scope: "team",
663
+ inputs: ["dateStart", "dateEnd"],
664
+ requires: ["dateStart", "dateEnd"]
665
+ },
666
+ "lift-progress-team": {
667
+ path: "/v5/analytics/lift-progress/teams",
668
+ scope: "team",
669
+ inputs: [
670
+ "exerciseId",
671
+ "dateStart",
672
+ "dateEnd"
673
+ ],
674
+ requires: [
675
+ "exerciseId",
676
+ "dateStart",
677
+ "dateEnd"
678
+ ]
679
+ },
680
+ "working-max-history": {
681
+ path: "/v5/analytics/working-max-history/users",
682
+ scope: "user",
683
+ inputs: [
684
+ "exerciseId",
685
+ "dateStart",
686
+ "dateEnd",
687
+ "useMetric"
688
+ ],
689
+ requires: ["exerciseId"]
690
+ }
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
+ };
699
+ /** Analytics report pulls. Read-only despite the POST verb, so ungated. */
700
+ function registerAnalyticsTools(server, ctx) {
701
+ server.registerTool("analytics_query", {
702
+ title: "Pull an analytics report",
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.",
704
+ inputSchema: {
705
+ metric: z.enum(METRIC_KEYS),
706
+ teamId: idParam.optional(),
707
+ userIds: z.array(idParam).optional(),
708
+ exerciseId: idParam.optional(),
709
+ date: z.string().optional(),
710
+ dateStart: z.string().optional(),
711
+ dateEnd: z.string().optional(),
712
+ useMetric: z.boolean().optional()
713
+ },
714
+ annotations: READ
715
+ }, async ({ metric, teamId, userIds, exerciseId, date, dateStart, dateEnd, useMetric }) => {
716
+ const spec = METRICS[metric];
717
+ const inputs = {
718
+ date,
719
+ dateStart,
720
+ dateEnd,
721
+ exerciseId,
722
+ useMetric
723
+ };
724
+ const body = {};
725
+ if (spec.scope === "team") {
726
+ if (teamId === void 0) return errorResult(`${metric} needs teamId.`);
727
+ body.teamId = toId(teamId);
728
+ } else {
729
+ if (userIds === void 0 || userIds.length === 0) return errorResult(`${metric} needs userIds (one or more athlete ids).`);
730
+ body.user_ids = userIds.map((u) => String(toId(u)));
731
+ }
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
+ }
739
+ return apiCall(ctx, "POST", spec.path, { body }, "Narrow with a tighter date range or fewer athletes.");
333
740
  });
334
741
  }
335
742
  //#endregion
@@ -343,7 +750,7 @@ function registerExerciseTools(server, ctx) {
343
750
  const index = ctx.index;
344
751
  server.registerTool("exercise_resolve", {
345
752
  title: "Resolve exercise name",
346
- 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.",
753
+ 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. Each result's `units` array lists the fixed measurement units by entry slot; they are fixed per exercise — check them before prescribing.",
347
754
  inputSchema: { name: z.string().min(1) },
348
755
  annotations: READ
349
756
  }, ({ name }) => attempt(async () => jsonResult(await index.resolve(name))));
@@ -407,8 +814,8 @@ function registerExerciseTools(server, ctx) {
407
814
  }
408
815
  //#endregion
409
816
  //#region src/tools/workout.ts
410
- /** Workout building, read-back, publishing, and removal. */
411
- function registerWorkoutTools(server, ctx) {
817
+ /** Build a draft, read it back, and publish it. */
818
+ function registerBuild(server, ctx) {
412
819
  server.registerTool("workout_build", {
413
820
  title: "Build a workout session (draft)",
414
821
  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.",
@@ -481,6 +888,9 @@ function registerWorkoutTools(server, ctx) {
481
888
  readback: await readSession(ctx.client, programId, parseWorkoutDate(date), pwId)
482
889
  });
483
890
  }));
891
+ }
892
+ /** Calendar lifecycle for an existing session: remove, unpublish, copy, save to library. */
893
+ function registerLifecycle(server, ctx) {
484
894
  server.registerTool("session_remove", {
485
895
  title: "Remove a session",
486
896
  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).",
@@ -499,6 +909,66 @@ function registerWorkoutTools(server, ctx) {
499
909
  await removeSession(ctx.client, programId, pwId);
500
910
  return jsonResult({ removed: pwId });
501
911
  }));
912
+ server.registerTool("session_unpublish", {
913
+ title: "Unpublish a session",
914
+ description: "Unpublish a previously published session (POST .../programWorkout/unPublish/{pwId}). It is no longer athlete-facing. Requires confirmation (elicitation, or confirm:true).",
915
+ inputSchema: {
916
+ pwId: z.number(),
917
+ confirm: z.boolean().optional()
918
+ },
919
+ annotations: {
920
+ readOnlyHint: false,
921
+ destructiveHint: true,
922
+ openWorldHint: true
923
+ }
924
+ }, ({ pwId, confirm }, extra) => attempt(async () => {
925
+ if (!await confirmGate(server, extra.requestId, `Unpublish session ${pwId}? Athletes will no longer see it.`, confirm)) return errorResult(NOT_CONFIRMED);
926
+ return apiCall(ctx, "POST", `/2.0/coach/calendar/programWorkout/unPublish/${pwId}`);
927
+ }));
928
+ server.registerTool("session_copy", {
929
+ title: "Copy a session to a date",
930
+ description: "Copy/repeat a session to a target date on a program (POST .../copyProgramWorkout). toDate is YYYY-M-D. Creates a new session; review and publish it separately.",
931
+ inputSchema: {
932
+ toProgramId: z.number(),
933
+ pwId: z.number(),
934
+ toDate: z.string()
935
+ },
936
+ annotations: {
937
+ readOnlyHint: false,
938
+ destructiveHint: false,
939
+ openWorldHint: true
940
+ }
941
+ }, ({ toProgramId, pwId, toDate }) => attempt(async () => {
942
+ const [year, month, day] = parseWorkoutDate(toDate);
943
+ const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
944
+ return apiCall(ctx, "POST", "/2.0/coach/calendar/copyProgramWorkout", { body: {
945
+ toProgramId,
946
+ pwId,
947
+ toDate: {
948
+ date: `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
949
+ day,
950
+ month,
951
+ year,
952
+ dayOfWeek,
953
+ isToday: false
954
+ }
955
+ } });
956
+ }));
957
+ server.registerTool("session_save_as_template", {
958
+ title: "Save a session to the library",
959
+ description: "Save an existing session as a reusable template in the session library (POST .../programWorkout/saveWorkoutAsTemplate/{workoutId}). Pass the workout_id.",
960
+ inputSchema: { workoutId: z.number() },
961
+ annotations: {
962
+ readOnlyHint: false,
963
+ destructiveHint: false,
964
+ openWorldHint: true
965
+ }
966
+ }, ({ workoutId }) => apiCall(ctx, "POST", `/2.0/coach/calendar/programWorkout/saveWorkoutAsTemplate/${workoutId}`));
967
+ }
968
+ /** Workout building, read-back, publishing, and the session calendar lifecycle. */
969
+ function registerWorkoutTools(server, ctx) {
970
+ registerBuild(server, ctx);
971
+ registerLifecycle(server, ctx);
502
972
  }
503
973
  //#endregion
504
974
  //#region src/tools/messaging.ts
@@ -581,4 +1051,4 @@ function registerMessagingTools(server, ctx) {
581
1051
  registerWrites(server, ctx);
582
1052
  }
583
1053
  //#endregion
584
- export { DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerExerciseTools, registerMessagingTools, registerRawTools, registerReadTools, 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.2.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.2.0",
29
- "@trainheroic-unofficial/js": "0.2.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",