@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 +12 -7
- package/dist/index.d.mts +36 -7
- package/dist/index.mjs +503 -33
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -16,7 +16,9 @@ functions:
|
|
|
16
16
|
```ts
|
|
17
17
|
import {
|
|
18
18
|
registerReadTools,
|
|
19
|
-
|
|
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),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,
|
|
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/
|
|
66
|
+
//#region src/tools/athlete-training.d.ts
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
229
|
-
function
|
|
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/
|
|
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
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
},
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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.
|
|
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
|
-
/**
|
|
411
|
-
function
|
|
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,
|
|
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
|
+
"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.
|
|
29
|
-
"@trainheroic-unofficial/js": "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",
|