@trainheroic-unofficial/cli 0.4.1 → 0.5.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
@@ -4,6 +4,13 @@ A command-line tool for the TrainHeroic coaching API. Takes credentials from the
4
4
 
5
5
  Part of the [trainheroic-unofficial](../../README.md) workspace.
6
6
 
7
+ ## Contents
8
+
9
+ - [Install](#install)
10
+ - [Usage](#usage)
11
+ - [Conventions](#conventions)
12
+ - [Develop](#develop)
13
+
7
14
  ## Install
8
15
 
9
16
  ```bash
@@ -11,39 +18,99 @@ npm install -g @trainheroic-unofficial/cli
11
18
  # or: npx @trainheroic-unofficial/cli <command>
12
19
  ```
13
20
 
21
+ Needs Node >= 18 and an existing TrainHeroic account.
22
+
14
23
  ## Usage
15
24
 
25
+ Set your TrainHeroic login in the environment (stored in plaintext and shell history — treat
26
+ it as a secret), then run a command. Every command prints JSON to stdout.
27
+
16
28
  ```bash
17
29
  export TRAINHEROIC_EMAIL="coach@example.com"
18
30
  export TRAINHEROIC_PASSWORD="..."
19
31
 
20
- trainheroic whoami
21
- trainheroic exercise resolve "Back Squat"
22
- trainheroic workout build --program 12345 --date 2026-6-22 --file day.json
32
+ trainheroic whoami # confirm auth; prints your account
33
+ trainheroic coach exercise resolve "Back Squat" # name -> exercise (id, units)
23
34
  ```
24
35
 
25
- During development, run from source with `pnpm start <args>`; after `pnpm build`, the
26
- `trainheroic` binary (`dist/cli.mjs`) does the same.
36
+ **Driving this from an AI agent?** Run `trainheroic skill` first it prints the full
37
+ workflow guide (with copy-paste workout-spec examples) to stdout, version-matched to the
38
+ binary; `trainheroic skill --full` adds the API and workout-creation reference docs. It's the
39
+ fastest way to get the spec shapes right instead of guessing from flags.
40
+
41
+ Commands split into three groups. `whoami` and `request` are **shared** (role-agnostic).
42
+ Everything for managing a roster lives under **`coach`** — reads (`coach athletes`,
43
+ `coach programs`, `coach teams`, `coach program <id>`, …), the exercise library
44
+ (`coach exercise resolve|search|get|sync|create|forget|stats`), the workout lifecycle
45
+ (`coach workout build|read|publish|remove`), and messaging
46
+ (`coach message list|read|draft|send|delete`). Your own training lives under **`athlete`**.
47
+ Run `trainheroic` with no command for the full reference. Dates are `Y-M-D` with a 1-based
48
+ month, e.g. `2026-6-22` or `2026-06-22` for 22 June 2026.
27
49
 
28
- Run `trainheroic` with no command to print the full help. Commands group into reads
29
- (`whoami`, `athletes`, `programs`, `teams`, `program <id>`, and the rest), a raw `request`
30
- escape hatch, exercise-library operations (`resolve`, `search`, `get`, `sync`, `create`,
31
- `forget`, `stats`), the workout lifecycle (`build`, `read`, `publish`, `remove`), and
32
- messaging (`list`, `read`, `draft`, `send`, `delete`).
50
+ ### Building a workout
51
+
52
+ `coach workout build` reads a workout spec — a JSON file (`--file`), an inline argument, or
53
+ stdin — and writes it into a program on a date. `--program` is a program id (find it in the
54
+ program's URL in the TrainHeroic web app, or run `trainheroic coach programs`). The spec is
55
+ `{ blocks, instruction? }` (a bare blocks array is also accepted). Each exercise's `id` is a
56
+ library id; get one with `coach exercise resolve`. `reps` and `weight` take a scalar or a
57
+ per-set array (`"reps": [5, 5, 3]`); loads use the exercise's configured unit. The full schema
58
+ is `WorkoutSpec` in [`@trainheroic-unofficial/dto`](../dto).
59
+
60
+ ```jsonc
61
+ // day.json — one block with one exercise (id 41822 is a library exercise id)
62
+ {
63
+ "instruction": "Warm up first.",
64
+ "blocks": [
65
+ {
66
+ "title": "Strength",
67
+ "exercises": [{ "id": 41822, "sets": 5, "reps": 5, "weight": 225, "rpe": 8 }],
68
+ },
69
+ ],
70
+ }
71
+ ```
72
+
73
+ ```bash
74
+ # Build as a draft (988 is the program id):
75
+ trainheroic coach workout build --program 988 --date 2026-6-22 --file day.json
76
+
77
+ # Build and publish to athletes (publishing is athlete-facing, so it needs --yes):
78
+ trainheroic coach workout build --program 988 --date 2026-6-22 --file day.json --publish --yes
79
+ ```
80
+
81
+ ### Raw requests
82
+
83
+ For an endpoint with no dedicated command, call it directly:
84
+
85
+ ```bash
86
+ trainheroic request GET /user/simple
87
+ trainheroic request POST /some/path '{"key":"value"}' --base apis
88
+ ```
89
+
90
+ `--base` selects the host: `coach` (the default, `api.trainheroic.com`) or `apis`
91
+ (`apis.trainheroic.com`). The body can be inline JSON (as above) or `--file <path>`.
33
92
 
34
93
  ## Conventions
35
94
 
36
- - Output is JSON on stdout; errors go to stderr with a non-zero exit code.
37
- - Actions that touch an athlete or delete data require an explicit `--yes`. That covers
38
- `workout publish`, `workout remove`, `exercise forget`, `message send`, `message delete`,
39
- and `workout build --publish`.
95
+ - Output is JSON on stdout; errors go to stderr with a non-zero exit code and a readable
96
+ validation path on bad input.
97
+ - Actions that touch an athlete or delete data require an explicit `--yes`:
98
+ `coach workout publish`, `coach workout remove`, `coach exercise forget`,
99
+ `coach message send`, `coach message delete`, `athlete log-set`, and
100
+ `coach workout build --publish`. (`coach workout build` alone creates a draft; `--publish`
101
+ makes it visible to athletes, which is why that combination needs `--yes`.)
40
102
  - JSON input can be passed inline, with `--file <path>`, or piped on stdin. Inputs are
41
- validated against the shared schemas from `@trainheroic-unofficial/dto`.
103
+ validated against the shared schemas from `@trainheroic-unofficial/dto` before sending.
42
104
  - The session token and the exercise library are cached under `~/.trainheroic/`. The library
43
- file is the same shape the local server uses, so the two share a cache.
105
+ file matches the shape the local coach server (`@trainheroic-unofficial/coach-mcp`) writes,
106
+ so the CLI and that server share one cache.
44
107
 
45
108
  ## Develop
46
109
 
110
+ Run `pnpm install` once at the repo root (Node >= 22, pnpm 10), then from this package. During
111
+ development, run from source with `pnpm start <args>`; after `pnpm build`, the `trainheroic`
112
+ binary (`dist/cli.mjs`) does the same.
113
+
47
114
  ```bash
48
115
  pnpm start whoami # tsx src/cli.ts
49
116
  pnpm build # tsdown -> dist/cli.mjs
package/dist/cli.mjs CHANGED
@@ -1448,38 +1448,48 @@ async function saveSession(sessionId) {
1448
1448
  const HELP = `trainheroic — command-line tool for the TrainHeroic coaching API
1449
1449
 
1450
1450
  Credentials come from TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD. Output is JSON.
1451
+ Coaching commands live under 'coach'; your own training lives under 'athlete'.
1452
+
1453
+ Start here (for AI agents):
1454
+ trainheroic skill full workflow guide + copy-paste examples (esp. workout specs)
1455
+ trainheroic skill --full also print the API + workout-creation reference docs
1456
+ trainheroic skill list list the available guides (coach, athlete)
1451
1457
 
1452
1458
  Setup:
1453
1459
  install-skill copy the Claude Code skills to ~/.claude/skills/
1454
1460
 
1455
- Reads:
1456
- whoami | head-coach | athletes | programs | teams | notifications | analytics
1457
- program <id> | team <id> | team-codes <id>
1458
- request <METHOD> <path> [json] [--base coach|apis] [--file f] raw API call
1461
+ Shared:
1462
+ whoami the logged-in account
1463
+ request <METHOD> <path> [json] [--base coach|apis] [--file f] raw API call (--base defaults to coach)
1464
+
1465
+ Coach — manage a roster (needs a coach account):
1466
+ coach head-coach | athletes | programs | teams | notifications | analytics
1467
+ coach program <id> | team <id> | team-codes <id>
1459
1468
 
1460
- Exercises (cached at ~/.trainheroic/library.json):
1461
- exercise resolve <name>
1462
- exercise search <query> [--limit N]
1463
- exercise get <id>
1464
- exercise sync [--force]
1465
- exercise create <json>|--file f
1466
- exercise forget <id> --yes
1467
- exercise stats
1469
+ exercise library (cached at ~/.trainheroic/library.json):
1470
+ coach exercise resolve <name>
1471
+ coach exercise search <query> [--limit N]
1472
+ coach exercise get <id>
1473
+ coach exercise sync [--force]
1474
+ coach exercise create <json>|--file f
1475
+ coach exercise forget <id> --yes
1476
+ coach exercise stats
1468
1477
 
1469
- Workouts:
1470
- workout build --program <id> (--date Y-M-D | --timeline-day <n>) [--publish --yes] <spec.json>|--file f
1471
- workout read --program <id> --date Y-M-D --pw <id>
1472
- workout publish --pw <id> --yes
1473
- workout remove --program <id> --pw <id> --yes
1478
+ workouts (spec: {"blocks":[{"title","exercises":[{"id","sets"?,"reps"?,"weight"?,"rpe"?}]}],"instruction"?};
1479
+ reps/weight may be a scalar or per-set array; omit --publish to leave a draft):
1480
+ coach workout build --program <id> (--date Y-M-D | --timeline-day <n>) [--publish --yes] <spec.json>|--file f
1481
+ coach workout read --program <id> --date Y-M-D --pw <id>
1482
+ coach workout publish --pw <id> --yes
1483
+ coach workout remove --program <id> --pw <id> --yes
1474
1484
 
1475
- Messaging:
1476
- message list
1477
- message read <streamId> [--limit N]
1478
- message draft <streamId> <text> [--reply-to <id>]
1479
- message send <streamId> <text> [--reply-to <id>] --yes
1480
- message delete <streamId> <commentId> --yes
1485
+ messaging:
1486
+ coach message list
1487
+ coach message read <streamId> [--limit N]
1488
+ coach message draft <streamId> <text> [--reply-to <id>]
1489
+ coach message send <streamId> <text> [--reply-to <id>] --yes
1490
+ coach message delete <streamId> <commentId> --yes
1481
1491
 
1482
- Athlete (the logged-in user's own training; a coach account works too):
1492
+ Athlete the logged-in user's own training (a coach account works too):
1483
1493
  athlete whoami | profile [--metric] | prefs | working-maxes
1484
1494
  athlete workouts --start Y-M-D --end Y-M-D [--raw]
1485
1495
  athlete exercises [--q <text>] [--limit N]
@@ -1507,9 +1517,9 @@ function toInt(value, label) {
1507
1517
  return n;
1508
1518
  }
1509
1519
  /** Validate input against a dto schema, failing with a readable message on mismatch. */
1510
- function validate(schema, value, label) {
1520
+ function validate(schema, value, label, hint) {
1511
1521
  const result = schema.safeParse(value);
1512
- if (!result.success) fail(`invalid ${label} — ${result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
1522
+ if (!result.success) fail(`invalid ${label} — ${result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}${hint ? `\n${hint}` : ""}`);
1513
1523
  return result.data;
1514
1524
  }
1515
1525
  function parse(args, options) {
@@ -1577,14 +1587,14 @@ async function cmdExercise(client, rest) {
1577
1587
  const [sub, ...a] = rest;
1578
1588
  const lib = library(client);
1579
1589
  switch (sub) {
1580
- case "resolve": return out(await lib.resolve(need(a.join(" ").trim() || void 0, "exercise resolve <name>")));
1590
+ case "resolve": return out(await lib.resolve(need(a.join(" ").trim() || void 0, "coach exercise resolve <name>")));
1581
1591
  case "search": {
1582
1592
  const { values, positionals } = parse(a, { limit: { type: "string" } });
1583
- const query = need(positionals.join(" ").trim() || void 0, "exercise search <query>");
1593
+ const query = need(positionals.join(" ").trim() || void 0, "coach exercise search <query>");
1584
1594
  const limit = values.limit !== void 0 ? toInt(values.limit, "--limit") : 20;
1585
1595
  return out(await lib.search(query, limit));
1586
1596
  }
1587
- case "get": return out(await lib.get(toInt(need(a[0], "exercise get <id>"), "id")));
1597
+ case "get": return out(await lib.get(toInt(need(a[0], "coach exercise get <id>"), "id")));
1588
1598
  case "sync": {
1589
1599
  const { values } = parse(a, { force: { type: "boolean" } });
1590
1600
  if (values.force === true) return out(await lib.refresh());
@@ -1598,13 +1608,13 @@ async function cmdExercise(client, rest) {
1598
1608
  }
1599
1609
  case "forget": {
1600
1610
  const { values, positionals } = parse(a, { yes: { type: "boolean" } });
1601
- const id = toInt(need(positionals[0], "exercise forget <id> --yes"), "id");
1611
+ const id = toInt(need(positionals[0], "coach exercise forget <id> --yes"), "id");
1602
1612
  if (values.yes !== true) fail(`add --yes to forget exercise ${id} from the local cache.`);
1603
1613
  await lib.recordDelete(id);
1604
1614
  return out({ forgotten: id });
1605
1615
  }
1606
1616
  case "stats": return out(await lib.stats());
1607
- default: return fail("usage: trainheroic exercise <resolve|search|get|sync|create|forget|stats>");
1617
+ default: return fail("usage: trainheroic coach exercise <resolve|search|get|sync|create|forget|stats>");
1608
1618
  }
1609
1619
  }
1610
1620
  async function cmdWorkout(client, rest) {
@@ -1619,10 +1629,10 @@ async function cmdWorkout(client, rest) {
1619
1629
  yes: { type: "boolean" },
1620
1630
  file: { type: "string" }
1621
1631
  });
1622
- const programId = toInt(need(values.program, "workout build --program <id> ..."), "--program");
1632
+ const programId = toInt(need(values.program, "coach workout build --program <id> ..."), "--program");
1623
1633
  if (values.date === void 0 && values["timeline-day"] === void 0) fail("provide --date YYYY-M-D or --timeline-day <n>.");
1624
1634
  const parsed = await jsonInput(positionals[0], values.file);
1625
- const spec = validate(workoutSpecSchema, Array.isArray(parsed) ? { blocks: parsed } : parsed, "workout spec");
1635
+ const spec = validate(workoutSpecSchema, Array.isArray(parsed) ? { blocks: parsed } : parsed, "workout spec", "run 'trainheroic skill' for the workout spec format and copy-paste examples.");
1626
1636
  const publish = values.publish === true;
1627
1637
  if (publish && values.yes !== true) fail("publishing is athlete-facing; add --yes to build and publish.");
1628
1638
  const opts = {
@@ -1649,14 +1659,14 @@ async function cmdWorkout(client, rest) {
1649
1659
  date: { type: "string" },
1650
1660
  pw: { type: "string" }
1651
1661
  });
1652
- return out(await readSession(client, toInt(need(values.program, "workout read --program <id> --date Y-M-D --pw <id>"), "--program"), parseWorkoutDate(need(values.date, "workout read --program <id> --date Y-M-D --pw <id>")), toInt(need(values.pw, "workout read --program <id> --date Y-M-D --pw <id>"), "--pw")));
1662
+ return out(await readSession(client, toInt(need(values.program, "coach workout read --program <id> --date Y-M-D --pw <id>"), "--program"), parseWorkoutDate(need(values.date, "coach workout read --program <id> --date Y-M-D --pw <id>")), toInt(need(values.pw, "coach workout read --program <id> --date Y-M-D --pw <id>"), "--pw")));
1653
1663
  }
1654
1664
  case "publish": {
1655
1665
  const { values } = parse(a, {
1656
1666
  pw: { type: "string" },
1657
1667
  yes: { type: "boolean" }
1658
1668
  });
1659
- const pw = toInt(need(values.pw, "workout publish --pw <id> --yes"), "--pw");
1669
+ const pw = toInt(need(values.pw, "coach workout publish --pw <id> --yes"), "--pw");
1660
1670
  if (values.yes !== true) fail(`publishing makes pw ${pw} athlete-visible; add --yes.`);
1661
1671
  await publishSession(client, pw);
1662
1672
  return out({ published: pw });
@@ -1667,13 +1677,13 @@ async function cmdWorkout(client, rest) {
1667
1677
  pw: { type: "string" },
1668
1678
  yes: { type: "boolean" }
1669
1679
  });
1670
- const programId = toInt(need(values.program, "workout remove --program <id> --pw <id> --yes"), "--program");
1671
- const pw = toInt(need(values.pw, "workout remove --program <id> --pw <id> --yes"), "--pw");
1680
+ const programId = toInt(need(values.program, "coach workout remove --program <id> --pw <id> --yes"), "--program");
1681
+ const pw = toInt(need(values.pw, "coach workout remove --program <id> --pw <id> --yes"), "--pw");
1672
1682
  if (values.yes !== true) fail(`removing pw ${pw} deletes the session; add --yes.`);
1673
1683
  await removeSession(client, programId, pw);
1674
1684
  return out({ removed: pw });
1675
1685
  }
1676
- default: return fail("usage: trainheroic workout <build|read|publish|remove>");
1686
+ default: return fail("usage: trainheroic coach workout <build|read|publish|remove>");
1677
1687
  }
1678
1688
  }
1679
1689
  async function cmdMessage(client, rest) {
@@ -1688,14 +1698,14 @@ async function cmdMessage(client, rest) {
1688
1698
  })));
1689
1699
  case "read": {
1690
1700
  const { values, positionals } = parse(a, { limit: { type: "string" } });
1691
- return out(await readLive(client, toInt(need(positionals[0], "message read <streamId> [--limit N]"), "streamId"), values.limit !== void 0 ? toInt(values.limit, "--limit") : 20));
1701
+ return out(await readLive(client, toInt(need(positionals[0], "coach message read <streamId> [--limit N]"), "streamId"), values.limit !== void 0 ? toInt(values.limit, "--limit") : 20));
1692
1702
  }
1693
1703
  case "draft": {
1694
1704
  const { values, positionals } = parse(a, { "reply-to": { type: "string" } });
1695
1705
  return out({
1696
1706
  draft: true,
1697
1707
  note: "NOT sent. Run 'message send' with --yes to deliver.",
1698
- payload: buildCommentPayload(toInt(need(positionals[0], "message draft <streamId> <text>"), "streamId"), need(positionals.slice(1).join(" ").trim() || void 0, "message draft <streamId> <text>"), values["reply-to"] !== void 0 ? toInt(values["reply-to"], "--reply-to") : null)
1708
+ payload: buildCommentPayload(toInt(need(positionals[0], "coach message draft <streamId> <text>"), "streamId"), need(positionals.slice(1).join(" ").trim() || void 0, "coach message draft <streamId> <text>"), values["reply-to"] !== void 0 ? toInt(values["reply-to"], "--reply-to") : null)
1699
1709
  });
1700
1710
  }
1701
1711
  case "send": {
@@ -1703,8 +1713,8 @@ async function cmdMessage(client, rest) {
1703
1713
  "reply-to": { type: "string" },
1704
1714
  yes: { type: "boolean" }
1705
1715
  });
1706
- const streamId = toInt(need(positionals[0], "message send <streamId> <text> --yes"), "streamId");
1707
- const text = need(positionals.slice(1).join(" ").trim() || void 0, "message send <streamId> <text> --yes");
1716
+ const streamId = toInt(need(positionals[0], "coach message send <streamId> <text> --yes"), "streamId");
1717
+ const text = need(positionals.slice(1).join(" ").trim() || void 0, "coach message send <streamId> <text> --yes");
1708
1718
  const replyTo = values["reply-to"] !== void 0 ? toInt(values["reply-to"], "--reply-to") : null;
1709
1719
  if (values.yes !== true) fail(`sending to stream ${streamId} is athlete-facing and immediate; add --yes.`);
1710
1720
  return out({
@@ -1714,15 +1724,15 @@ async function cmdMessage(client, rest) {
1714
1724
  }
1715
1725
  case "delete": {
1716
1726
  const { values, positionals } = parse(a, { yes: { type: "boolean" } });
1717
- const streamId = toInt(need(positionals[0], "message delete <streamId> <commentId> --yes"), "streamId");
1718
- const commentId = toInt(need(positionals[1], "message delete <streamId> <commentId> --yes"), "commentId");
1727
+ const streamId = toInt(need(positionals[0], "coach message delete <streamId> <commentId> --yes"), "streamId");
1728
+ const commentId = toInt(need(positionals[1], "coach message delete <streamId> <commentId> --yes"), "commentId");
1719
1729
  if (values.yes !== true) fail(`deleting comment ${commentId} acts on the live account; add --yes.`);
1720
1730
  return out({
1721
1731
  deleted: true,
1722
1732
  response: await deleteComment(client, streamId, commentId)
1723
1733
  });
1724
1734
  }
1725
- default: return fail("usage: trainheroic message <list|read|draft|send|delete>");
1735
+ default: return fail("usage: trainheroic coach message <list|read|draft|send|delete>");
1726
1736
  }
1727
1737
  }
1728
1738
  function isoDate(value, label) {
@@ -1883,6 +1893,28 @@ async function cmdAthlete(client, rest) {
1883
1893
  default: return fail("usage: trainheroic athlete <whoami|profile|prefs|workouts|exercises|history|prs|stats|working-maxes|leaderboard|export|log-set>");
1884
1894
  }
1885
1895
  }
1896
+ async function cmdSkill(rest) {
1897
+ const skillRoot = join(import.meta.dirname, "../skill");
1898
+ const positional = rest.find((r) => !r.startsWith("-"));
1899
+ if (positional === "list") return out((await readdir(skillRoot, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name));
1900
+ const name = positional ?? "trainheroic-unofficial";
1901
+ const dir = join(skillRoot, name);
1902
+ let text;
1903
+ try {
1904
+ text = await readFile(join(dir, "SKILL.md"), "utf8");
1905
+ } catch {
1906
+ return fail(`unknown skill "${name}". Run 'trainheroic skill list'.`);
1907
+ }
1908
+ process.stdout.write(text);
1909
+ if (rest.includes("--full")) try {
1910
+ const refDir = join(dir, "references");
1911
+ const refs = (await readdir(refDir)).filter((f) => f.endsWith(".md")).sort();
1912
+ for (const f of refs) {
1913
+ process.stdout.write(`\n\n===== references/${f} =====\n\n`);
1914
+ process.stdout.write(await readFile(join(refDir, f), "utf8"));
1915
+ }
1916
+ } catch {}
1917
+ }
1886
1918
  async function cmdInstallSkill() {
1887
1919
  const home = process.env.HOME ?? process.env.USERPROFILE;
1888
1920
  if (!home) fail("cannot determine home directory.");
@@ -1902,22 +1934,30 @@ async function cmdInstallSkill() {
1902
1934
  }
1903
1935
  out({ installed });
1904
1936
  }
1905
- async function dispatch(client, group, rest) {
1906
- switch (group) {
1907
- case "whoami": return out(await get(client, "/user/simple"));
1937
+ const COACH_USAGE = "usage: trainheroic coach <head-coach|athletes|programs|teams|notifications|analytics|program <id>|team <id>|team-codes <id>|exercise|workout|message>";
1938
+ async function cmdCoach(client, rest) {
1939
+ const [sub, ...a] = rest;
1940
+ switch (sub) {
1908
1941
  case "head-coach": return out(await get(client, "/v5/headCoach"));
1909
1942
  case "athletes": return out(await get(client, "/v5/athletes"));
1910
1943
  case "programs": return out(await get(client, "/1.0/coach/programs"));
1911
1944
  case "teams": return out(await get(client, "/1.0/coach/teams"));
1912
1945
  case "notifications": return out(await get(client, "/v5/notifications/counts"));
1913
1946
  case "analytics": return out(await get(client, "/v5/analytics"));
1914
- case "program": return out(await get(client, `/3.0/coach/program/${encodeURIComponent(need(rest[0], "program <id>"))}`));
1915
- case "team": return out(await get(client, `/v5/teams/${encodeURIComponent(need(rest[0], "team <id>"))}`));
1916
- case "team-codes": return out(await get(client, `/v5/teams/${encodeURIComponent(need(rest[0], "team-codes <id>"))}/teamCodes`));
1947
+ case "program": return out(await get(client, `/3.0/coach/program/${encodeURIComponent(need(a[0], "coach program <id>"))}`));
1948
+ case "team": return out(await get(client, `/v5/teams/${encodeURIComponent(need(a[0], "coach team <id>"))}`));
1949
+ case "team-codes": return out(await get(client, `/v5/teams/${encodeURIComponent(need(a[0], "coach team-codes <id>"))}/teamCodes`));
1950
+ case "exercise": return cmdExercise(client, a);
1951
+ case "workout": return cmdWorkout(client, a);
1952
+ case "message": return cmdMessage(client, a);
1953
+ default: return fail(COACH_USAGE);
1954
+ }
1955
+ }
1956
+ async function dispatch(client, group, rest) {
1957
+ switch (group) {
1958
+ case "whoami": return out(await get(client, "/user/simple"));
1917
1959
  case "request": return cmdRequest(client, rest);
1918
- case "exercise": return cmdExercise(client, rest);
1919
- case "workout": return cmdWorkout(client, rest);
1920
- case "message": return cmdMessage(client, rest);
1960
+ case "coach": return cmdCoach(client, rest);
1921
1961
  case "athlete": return cmdAthlete(client, rest);
1922
1962
  default: return fail(`unknown command "${group}". Run 'trainheroic help'.`);
1923
1963
  }
@@ -1932,6 +1972,10 @@ async function main() {
1932
1972
  await cmdInstallSkill();
1933
1973
  return;
1934
1974
  }
1975
+ if (group === "skill") {
1976
+ await cmdSkill(rest);
1977
+ return;
1978
+ }
1935
1979
  const email = process.env.TRAINHEROIC_EMAIL;
1936
1980
  const password = process.env.TRAINHEROIC_PASSWORD;
1937
1981
  if (!email || !password) fail("set TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD in the environment.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/dto": "0.4.1",
25
- "@trainheroic-unofficial/js": "0.4.1"
24
+ "@trainheroic-unofficial/dto": "0.5.0",
25
+ "@trainheroic-unofficial/js": "0.5.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.0.0",
@@ -76,24 +76,25 @@ cat block.json | $TH request POST /2.0/coach/calendar/saveProgramWorkoutSets
76
76
  ```
77
77
 
78
78
  A few endpoints live on the login host (`apis.trainheroic.com`); reach them with
79
- `--base apis` (the default base is the coach host). Named shortcuts cover the common
80
- reads: `whoami`, `head-coach`, `athletes`, `programs`, `teams`, `notifications`,
81
- `analytics`, `program <id>`, `team <id>`, `team-codes <id>`.
79
+ `--base apis` (the default base is the coach host). `whoami` and `request` are top-level;
80
+ the coach reads live under the `coach` group: `coach head-coach`, `coach athletes`,
81
+ `coach programs`, `coach teams`, `coach notifications`, `coach analytics`,
82
+ `coach program <id>`, `coach team <id>`, `coach team-codes <id>`.
82
83
 
83
84
  Load `references/api-reference.md` when you need an endpoint's exact request or
84
85
  response shape, or an area not covered here.
85
86
 
86
87
  ## What you can do
87
88
 
88
- | Area | How |
89
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
90
- | Athletes | `$TH athletes`; invite/archive/restore via `$TH request ...` |
91
- | Teams | `$TH teams`, `$TH team <id>`, `$TH team-codes <id>`; create via `$TH request POST /1.0/coach/team/createWithTitleAndCode` |
92
- | Programs | `$TH programs`, `$TH program <id>` |
93
- | Exercises | `$TH exercise resolve\|search\|get\|sync\|create\|forget\|stats` (below) |
94
- | Sessions / workouts | `$TH workout build\|read\|publish\|remove` (below) |
95
- | Messaging | `$TH message list\|read\|draft\|send\|delete` (below) |
96
- | Analytics | `$TH analytics`, then `$TH request POST /v5/analytics/*` |
89
+ | Area | How |
90
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | Athletes | `$TH coach athletes`; invite/archive/restore via `$TH request ...` |
92
+ | Teams | `$TH coach teams`, `$TH coach team <id>`, `$TH coach team-codes <id>`; create via `$TH request POST /1.0/coach/team/createWithTitleAndCode` |
93
+ | Programs | `$TH coach programs`, `$TH coach program <id>` |
94
+ | Exercises | `$TH coach exercise resolve\|search\|get\|sync\|create\|forget\|stats` (below) |
95
+ | Sessions / workouts | `$TH coach workout build\|read\|publish\|remove` (below) |
96
+ | Messaging | `$TH coach message list\|read\|draft\|send\|delete` (below) |
97
+ | Analytics | `$TH coach analytics`, then `$TH request POST /v5/analytics/*` |
97
98
 
98
99
  ## Resolving exercises
99
100
 
@@ -102,9 +103,9 @@ cache, which mirrors the library into `~/.trainheroic/library.json` and serves l
102
103
  locally:
103
104
 
104
105
  ```bash
105
- $TH exercise resolve "Back Squat" # exact/best match -> id (+ unit labels)
106
- $TH exercise search "incline press" # ranked candidates
107
- $TH exercise sync --force # refresh the mirror (7-day TTL otherwise)
106
+ $TH coach exercise resolve "Back Squat" # exact/best match -> id (+ unit labels)
107
+ $TH coach exercise search "incline press" # ranked candidates
108
+ $TH coach exercise sync --force # refresh the mirror (7-day TTL otherwise)
108
109
  ```
109
110
 
110
111
  `resolve` returns `match: null` plus a candidate list when a name is ambiguous; pick the
@@ -114,11 +115,11 @@ After creating a custom exercise, go through the cache so the mirror stays curre
114
115
  deleting one via the API, drop it from the mirror:
115
116
 
116
117
  ```bash
117
- $TH exercise create '{"title":"Sandbag Clean","param_1_type":3,"param_2_type":1}'
118
- $TH exercise forget 7721170 --yes # cache-only, run after an API delete
118
+ $TH coach exercise create '{"title":"Sandbag Clean","param_1_type":3,"param_2_type":1}'
119
+ $TH coach exercise forget 7721170 --yes # cache-only, run after an API delete
119
120
  ```
120
121
 
121
- `$TH exercise stats` shows the cached row count.
122
+ `$TH coach exercise stats` shows the cached row count.
122
123
 
123
124
  ## Building a workout
124
125
 
@@ -130,7 +131,7 @@ follow-up questions first; do not guess.** Restate the program you intend to bui
130
131
  explicit confirmation before publishing. Build a draft (the default), show the read-back
131
132
  for review, and only publish once the user confirms.
132
133
 
133
- `$TH workout build` runs the whole sequence (program → session → blocks → exercises),
134
+ `$TH coach workout build` runs the whole sequence (program → session → blocks → exercises),
134
135
  fills every field that otherwise makes the exercise step return HTTP 500, encodes
135
136
  prescriptions correctly, and prints unit advisories plus a read-back. Feed it a JSON spec
136
137
  (inline, `--file`, or stdin):
@@ -146,15 +147,15 @@ cat > day.json <<'JSON'
146
147
  ] }
147
148
  JSON
148
149
 
149
- $TH workout build --program 4980851 --date 2026-6-22 --file day.json # draft
150
- $TH workout publish --pw <id> --yes # publish after review
150
+ $TH coach workout build --program 4980851 --date 2026-6-22 --file day.json # draft
151
+ $TH coach workout publish --pw <id> --yes # publish after review
151
152
  ```
152
153
 
153
154
  The build defaults to a **draft** (unpublished). Add `--publish --yes` to publish in one
154
- step, or publish later with `$TH workout publish --pw <id> --yes`. Read a built session
155
- back with `$TH workout read --program <id> --date Y-M-D --pw <id>`. There is no in-place
155
+ step, or publish later with `$TH coach workout publish --pw <id> --yes`. Read a built session
156
+ back with `$TH coach workout read --program <id> --date Y-M-D --pw <id>`. There is no in-place
156
157
  replace: to rebuild a date, remove the old session first with
157
- `$TH workout remove --program <id> --pw <id> --yes`.
158
+ `$TH coach workout remove --program <id> --pw <id> --yes`.
158
159
 
159
160
  Two exercises in one block become a superset. Add `"leaderboard": "rounds"` (or
160
161
  `reps`/`time`/`calories`/`meters`/… or `{"unit":"time","lowest_wins":true}`) to a block to
@@ -178,17 +179,17 @@ draft state, so a POST is delivered at once. The CLI therefore separates draftin
178
179
  sending and requires `--yes` to actually send or delete.
179
180
 
180
181
  ```bash
181
- $TH message list # conversations (id, kind, title)
182
- $TH message read 37730920 # recent messages
183
- $TH message draft 37730920 "Great session today!" # PREVIEW only — never sends
184
- $TH message send 37730920 "Great session today!" --yes # delivers (gate behind user OK)
185
- $TH message send 37730920 "Nice PR!" --reply-to 125652586 --yes # threaded reply
186
- $TH message delete 37730920 <commentId> --yes # soft delete (destructive)
182
+ $TH coach message list # conversations (id, kind, title)
183
+ $TH coach message read 37730920 # recent messages
184
+ $TH coach message draft 37730920 "Great session today!" # PREVIEW only — never sends
185
+ $TH coach message send 37730920 "Great session today!" --yes # delivers (gate behind user OK)
186
+ $TH coach message send 37730920 "Nice PR!" --reply-to 125652586 --yes # threaded reply
187
+ $TH coach message delete 37730920 <commentId> --yes # soft delete (destructive)
187
188
  ```
188
189
 
189
190
  Default to `draft`: it prints the exact payload and target without touching the account.
190
191
  Run `send` only after the user confirms the wording and recipient. Check
191
- `$TH notifications` (`countMessagingNotViewed`) first as a cheap "anything new?" gate.
192
+ `$TH coach notifications` (`countMessagingNotViewed`) first as a cheap "anything new?" gate.
192
193
 
193
194
  ## Gotchas
194
195
 
@@ -204,7 +205,7 @@ Environment-specific facts that defy reasonable assumptions:
204
205
  "200 m run" on it shows as 200 _miles_. And `param_2_type` `2` (% of max) and `14` (RPE)
205
206
  coerce to weight on a weight lift, rendering as pounds — put % or RPE in the
206
207
  `instruction` (the builder does this from `rpe`). You _can_ add weight (`1`) to a
207
- no-secondary-param lift (weighted Pull-Ups). Check units with `$TH exercise resolve`
208
+ no-secondary-param lift (weighted Pull-Ups). Check units with `$TH coach exercise resolve`
208
209
  (the `units` array, ordered by entry slot `[param 1, param 2]`); the builder prints a
209
210
  warning when a sent type will be overridden. "Max"/"AMRAP" reps work as free text in the
210
211
  rep slots.
@@ -235,9 +236,9 @@ Environment-specific facts that defy reasonable assumptions:
235
236
  3. **offer the user the option to do it themselves** — hand them the exact command (e.g.
236
237
  `$TH request DELETE /v5/teamCodes/874586`) or the UI steps.
237
238
 
238
- The CLI enforces a `--yes` flag on `message send`/`message delete`, `workout publish`,
239
- `workout remove`, and `exercise forget`, but the gate above still applies: confirm in the
240
- moment before adding `--yes`.
239
+ The CLI enforces a `--yes` flag on `coach message send`/`coach message delete`,
240
+ `coach workout publish`, `coach workout remove`, and `coach exercise forget`, but the gate
241
+ above still applies: confirm in the moment before adding `--yes`.
241
242
 
242
243
  ## Beyond the CLI
243
244
 
@@ -554,7 +554,7 @@ This creates A1: Back Squat, A2: Front Squat displayed as a superset.
554
554
  > `Run` (id 82) is `10` (miles); sending `6` (meters) is ignored and `200` shows as
555
555
  > _200 miles_. To program meters, pick a meters-native exercise (`Sprint` 127,
556
556
  > `Rowing` 101, `Shuttle Sprint` 42523) or a custom exercise — there is no metric
557
- > "Run". `$TH exercise resolve` now prints a `units` array (ordered by entry slot,
557
+ > "Run". `$TH coach exercise resolve` now prints a `units` array (ordered by entry slot,
558
558
  > `[param 1, param 2]`); check it.
559
559
  > - **`param_2_type` (secondary) is forced to the default too, with one exception:**
560
560
  > if the exercise has no secondary param (default `0`/none) you may add weight
@@ -753,7 +753,7 @@ Creates a reusable session template in the library.
753
753
 
754
754
  Chat between a coach and athletes/teams. A **stream** is a conversation; a
755
755
  **comment** is a message in it. Verified against a live
756
- account (see `$TH message`).
756
+ account (see `$TH coach message`).
757
757
 
758
758
  | Method | Endpoint | Description |
759
759
  | ------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------ |
@@ -229,7 +229,7 @@ sent under the wrong assumed unit silently renders under the exercise's real uni
229
229
  (`1`) to an exercise that has no secondary param (default `0`/none) — that is how
230
230
  weighted Pull-Ups/Dips work. `2` (% of max) and `14` (RPE) do **not** stick on a
231
231
  weight-default lift; both coerce to weight and render as pounds.
232
- - Check an exercise's real units first: `$TH exercise resolve "<name>"` prints a
232
+ - Check an exercise's real units first: `$TH coach exercise resolve "<name>"` prints a
233
233
  `units` array, ordered by entry slot (`[param 1, param 2]`). the workout builder also
234
234
  prints a `WARNING` when a sent param type will be overridden, and its read-back labels
235
235
  the stored units.