@robinthues/rt-claude-coach 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +98 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +530 -0
- package/dist/db/client.d.ts +8 -0
- package/dist/db/client.js +111 -0
- package/dist/db/migrate.d.ts +2 -0
- package/dist/db/migrate.js +30 -0
- package/dist/db/schema.sql +108 -0
- package/dist/garmin/context.d.ts +31 -0
- package/dist/garmin/context.js +25 -0
- package/dist/garmin/convert.d.ts +28 -0
- package/dist/garmin/convert.js +117 -0
- package/dist/garmin/dto.d.ts +132 -0
- package/dist/garmin/dto.js +35 -0
- package/dist/garmin/index.d.ts +3 -0
- package/dist/garmin/index.js +1 -0
- package/dist/garmin/pace.d.ts +6 -0
- package/dist/garmin/pace.js +23 -0
- package/dist/garmin/steps.d.ts +12 -0
- package/dist/garmin/steps.js +147 -0
- package/dist/garmin/targets.d.ts +15 -0
- package/dist/garmin/targets.js +85 -0
- package/dist/lib/config.d.ts +27 -0
- package/dist/lib/config.js +86 -0
- package/dist/lib/logging.d.ts +13 -0
- package/dist/lib/logging.js +28 -0
- package/dist/schema/training-plan.d.ts +288 -0
- package/dist/schema/training-plan.js +88 -0
- package/dist/strava/api.d.ts +23 -0
- package/dist/strava/api.js +88 -0
- package/dist/strava/details.d.ts +11 -0
- package/dist/strava/details.js +50 -0
- package/dist/strava/oauth.d.ts +4 -0
- package/dist/strava/oauth.js +113 -0
- package/dist/strava/rate-limit.d.ts +9 -0
- package/dist/strava/rate-limit.js +16 -0
- package/dist/strava/store.d.ts +13 -0
- package/dist/strava/store.js +107 -0
- package/dist/strava/types.d.ts +49 -0
- package/dist/strava/types.js +1 -0
- package/dist/viewer/lib/export/erg.d.ts +26 -0
- package/dist/viewer/lib/export/erg.js +208 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +308 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +142 -0
- package/dist/viewer/lib/export/index.d.ts +50 -0
- package/dist/viewer/lib/export/index.js +229 -0
- package/dist/viewer/lib/export/zwo.d.ts +21 -0
- package/dist/viewer/lib/export/zwo.js +233 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +125 -0
- package/dist/viewer/main.d.ts +5 -0
- package/dist/viewer/main.js +6 -0
- package/dist/viewer/stores/changes.d.ts +21 -0
- package/dist/viewer/stores/changes.js +49 -0
- package/dist/viewer/stores/plan.d.ts +4 -0
- package/dist/viewer/stores/plan.js +19 -0
- package/dist/viewer/stores/settings.d.ts +53 -0
- package/dist/viewer/stores/settings.js +215 -0
- package/package.json +67 -0
- package/templates/plan-viewer.html +70 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Felix Rieseberg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Claude Coach
|
|
2
|
+
|
|
3
|
+
> This is Robin's fork of
|
|
4
|
+
> [felixrieseberg/claude-coach](https://github.com/felixrieseberg/claude-coach),
|
|
5
|
+
> published as
|
|
6
|
+
> [`@robinthues/rt-claude-coach`](https://www.npmjs.com/package/@robinthues/rt-claude-coach)
|
|
7
|
+
> (CLI: `npx @robinthues/rt-claude-coach`). All credit for the original
|
|
8
|
+
> project goes to Felix Rieseberg.
|
|
9
|
+
|
|
10
|
+
Claude Coach allows you to use Claude to create custom-tailored training programs for triathlons, marathons, and other endurance activities. Using a data-driven approach and principles from top training plans, Claude will create a training plan that's uniquely fit for you, your personal fitness, and the constraints you have in the next couple of weeks. Maybe you're recovering from an injury, maybe you're traveling and don't have access to a pool or track in a certain week - tell Claude about it and it'll create a plan that works for you.
|
|
11
|
+
|
|
12
|
+
The output is a beautiful training plan app that allows you to add, edit, or move workouts, mark them as complete, and update key training data like heart rate zones, LTHR, threshold paces, FTP, and others. Your data is kept locally in your browser.
|
|
13
|
+
|
|
14
|
+
Workouts can be exported as simple calendar events (.ics), Zwift (.zwo), Garmin (.fit), or TrainerRoad/ERG (.mrc) workouts.
|
|
15
|
+
|
|
16
|
+
## Examples
|
|
17
|
+
|
|
18
|
+
See example training plans at [felixrieseberg.github.io/claude-coach](https://felixrieseberg.github.io/claude-coach/#demos).
|
|
19
|
+
|
|
20
|
+
## Installation & Creating a training plan
|
|
21
|
+
|
|
22
|
+
I happen to work at Anthropic, so this tool is optimized for Claude. To use this tool, you need access to Claude.ai or Claude Code with network access for Skills. Depending on user/admin settings, Skills may have full, partial, or no network access.
|
|
23
|
+
|
|
24
|
+
Syncing all your Strava activities and creating a tailored training plan takes ca. 15 minutes.
|
|
25
|
+
|
|
26
|
+
### Installing the Skill
|
|
27
|
+
|
|
28
|
+
First, [download the latest skill from this repo's GitHub Releases](https://github.com/RobinThues/rt-claude-coach/releases/latest/download/coach-skill.zip) (the repo is private, so you must be logged in to GitHub).
|
|
29
|
+
|
|
30
|
+
**Claude.ai:**
|
|
31
|
+
|
|
32
|
+
1. Open [Claude.ai Settings](https://claude.ai/settings/capabilities)
|
|
33
|
+
2. Enable "Code execution and file creation"
|
|
34
|
+
3. In the allowed domains list, add `*.strava.com`
|
|
35
|
+
4. Scroll down to "Skills" and click "Add skill", then upload the `coach-skill.zip` file
|
|
36
|
+
|
|
37
|
+
**Claude Code:**
|
|
38
|
+
|
|
39
|
+
1. Run `/install-skill` and provide the path to the `coach-skill.zip` file you downloaded.
|
|
40
|
+
|
|
41
|
+
### Creating a plan
|
|
42
|
+
|
|
43
|
+
Use the most capable model available to you (as I'm writing this, that's Opus 4.5). Prompt Claude with something like this:
|
|
44
|
+
|
|
45
|
+
> Help me create a training plan for the Ironman 70.3 Oceanside on March 29th 2026 using the "coach" skill.
|
|
46
|
+
|
|
47
|
+
Claude will ask how you'd like to provide your fitness data. You have two options: You can either tell Claude about your fitness history manually - or you can give it access to your Strava activities. I recommend the later - data doesn't lie and more data allows Claude to make a training plan that really fits you.
|
|
48
|
+
|
|
49
|
+
#### Option 1: Connect to Strava (Recommended)
|
|
50
|
+
|
|
51
|
+
The easiest way to get a personalized plan is to let Claude analyze your Strava training history. This gives Claude real data about your current fitness, training patterns, and progress.
|
|
52
|
+
|
|
53
|
+
Claude needs a `Client ID` and `Client Secret` to access your Strava activities. You're only giving Claude access to your data - nobody else gets to see it.
|
|
54
|
+
|
|
55
|
+
1. Go to [strava.com/settings/api](https://www.strava.com/settings/api) and log in with your Strava account
|
|
56
|
+
2. You'll see a form titled "My API Application" - fill it out:
|
|
57
|
+
- **Application Name**: Enter anything you like (e.g., "Claude Coach")
|
|
58
|
+
- **Category**: Select "Data Importer"
|
|
59
|
+
- **Club**: Leave this blank
|
|
60
|
+
- **Website**: Enter any URL (e.g., `https://claude.ai`)
|
|
61
|
+
- **Application Description**: Enter anything (e.g., "Training plan generation")
|
|
62
|
+
- **Authorization Callback Domain**: Enter `localhost`
|
|
63
|
+
3. Check the box to agree to Strava's API Agreement and click **Create**
|
|
64
|
+
4. Copy your **Client ID** and **Client Secret** and give them to Claude when prompted
|
|
65
|
+
|
|
66
|
+
#### Option 2: Manual Entry
|
|
67
|
+
|
|
68
|
+
Don't use Strava, or prefer not to connect it? No problem. You can tell Claude about your fitness directly. Be prepared to share:
|
|
69
|
+
|
|
70
|
+
**Current Training (recent 4-8 weeks):**
|
|
71
|
+
|
|
72
|
+
- Weekly training hours by sport (swim/bike/run)
|
|
73
|
+
- Typical long session distances (longest ride, longest run, etc.)
|
|
74
|
+
- Training consistency (how many weeks have you been training regularly?)
|
|
75
|
+
|
|
76
|
+
**Performance Benchmarks (any you know):**
|
|
77
|
+
|
|
78
|
+
- Bike FTP (Functional Threshold Power) in watts
|
|
79
|
+
- Run threshold pace or recent race times (5K, 10K, half marathon, etc.)
|
|
80
|
+
- Swim CSS (Critical Swim Speed) or recent time trial (e.g., 1000m time)
|
|
81
|
+
- Max heart rate and/or lactate threshold heart rate
|
|
82
|
+
|
|
83
|
+
### Telling Claude about your event & constraints
|
|
84
|
+
|
|
85
|
+
In the next step, Claude will ask you about yourself, the event you're training for, and any constraints it should keep in mind. Examples of information you'd tell any coach:
|
|
86
|
+
|
|
87
|
+
- Years in the sport
|
|
88
|
+
- Previous races completed (distances and approximate times)
|
|
89
|
+
- Any recent breaks from training
|
|
90
|
+
- Injuries or health issues
|
|
91
|
+
- Schedule limitations (work travel, family, etc.)
|
|
92
|
+
- Equipment access (pool availability, trainer, etc.)
|
|
93
|
+
|
|
94
|
+
Claude will use this information to create a plan tailored to your current fitness level. The more detail you provide, the better your plan will be.
|
|
95
|
+
|
|
96
|
+
# About
|
|
97
|
+
|
|
98
|
+
Claude Coach is an independent, open-source project and is not made by, endorsed by, or affiliated with Anthropic, PBC. "Claude" is a trademark of Anthropic. This tool is a skill/plugin that works with Claude products but is developed and maintained independently. License: MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const cliPath = join(__dirname, "..", "dist", "cli.js");
|
|
8
|
+
|
|
9
|
+
// Dynamically import the compiled CLI
|
|
10
|
+
await import(cliPath);
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, createConfig, } from "./lib/config.js";
|
|
2
|
+
import { log } from "./lib/logging.js";
|
|
3
|
+
import { migrate } from "./db/migrate.js";
|
|
4
|
+
import { execute, initDatabase, query, queryJson } from "./db/client.js";
|
|
5
|
+
import { getValidTokens } from "./strava/oauth.js";
|
|
6
|
+
import { getAllActivities, getAthlete } from "./strava/api.js";
|
|
7
|
+
import { insertAthlete, upsertSummaryActivity } from "./strava/store.js";
|
|
8
|
+
import { syncActivityDetails } from "./strava/details.js";
|
|
9
|
+
import { convertPlan } from "./garmin/index.js";
|
|
10
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Proxy Configuration
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Configure proxy for fetch() if HTTP_PROXY or HTTPS_PROXY is set
|
|
19
|
+
const proxyUrl = process.env.HTTPS_PROXY ||
|
|
20
|
+
process.env.https_proxy ||
|
|
21
|
+
process.env.HTTP_PROXY ||
|
|
22
|
+
process.env.http_proxy;
|
|
23
|
+
if (proxyUrl) {
|
|
24
|
+
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
|
25
|
+
}
|
|
26
|
+
function parseArgs() {
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
if (args.length === 0 || args[0] === "sync") {
|
|
29
|
+
// Sync command (default)
|
|
30
|
+
const syncArgs = { command: "sync" };
|
|
31
|
+
for (const arg of args) {
|
|
32
|
+
if (arg.startsWith("--client-id=")) {
|
|
33
|
+
syncArgs.clientId = arg.split("=")[1];
|
|
34
|
+
}
|
|
35
|
+
else if (arg.startsWith("--client-secret=")) {
|
|
36
|
+
syncArgs.clientSecret = arg.split("=")[1];
|
|
37
|
+
}
|
|
38
|
+
else if (arg.startsWith("--access-token=")) {
|
|
39
|
+
syncArgs.accessToken = arg.split("=")[1];
|
|
40
|
+
}
|
|
41
|
+
else if (arg.startsWith("--refresh-token=")) {
|
|
42
|
+
syncArgs.refreshToken = arg.split("=")[1];
|
|
43
|
+
}
|
|
44
|
+
else if (arg.startsWith("--days=")) {
|
|
45
|
+
syncArgs.days = parseInt(arg.split("=")[1]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return syncArgs;
|
|
49
|
+
}
|
|
50
|
+
if (args[0] === "render") {
|
|
51
|
+
if (!args[1]) {
|
|
52
|
+
log.error("render command requires an input file");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const renderArgs = {
|
|
56
|
+
command: "render",
|
|
57
|
+
inputFile: args[1],
|
|
58
|
+
};
|
|
59
|
+
for (let i = 2; i < args.length; i++) {
|
|
60
|
+
if (args[i] === "--output" || args[i] === "-o") {
|
|
61
|
+
renderArgs.outputFile = args[i + 1];
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
else if (args[i].startsWith("--output=")) {
|
|
65
|
+
renderArgs.outputFile = args[i].split("=")[1];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return renderArgs;
|
|
69
|
+
}
|
|
70
|
+
if (args[0] === "export-garmin") {
|
|
71
|
+
if (!args[1]) {
|
|
72
|
+
log.error("export-garmin command requires an input plan JSON file");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const exportArgs = {
|
|
76
|
+
command: "export-garmin",
|
|
77
|
+
inputFile: args[1],
|
|
78
|
+
};
|
|
79
|
+
for (let i = 2; i < args.length; i++) {
|
|
80
|
+
if (args[i] === "--output" || args[i] === "-o") {
|
|
81
|
+
exportArgs.outputFile = args[i + 1];
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
else if (args[i].startsWith("--output=")) {
|
|
85
|
+
exportArgs.outputFile = args[i].slice("--output=".length);
|
|
86
|
+
}
|
|
87
|
+
else if (args[i].startsWith("--tag=")) {
|
|
88
|
+
exportArgs.tag = args[i].slice("--tag=".length);
|
|
89
|
+
}
|
|
90
|
+
else if (args[i].startsWith("--workout=")) {
|
|
91
|
+
exportArgs.workoutId = args[i].slice("--workout=".length);
|
|
92
|
+
}
|
|
93
|
+
else if (args[i].startsWith("--date=")) {
|
|
94
|
+
exportArgs.date = args[i].slice("--date=".length);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return exportArgs;
|
|
98
|
+
}
|
|
99
|
+
if (args[0] === "query") {
|
|
100
|
+
if (!args[1]) {
|
|
101
|
+
log.error("query command requires a SQL statement");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const queryArgs = {
|
|
105
|
+
command: "query",
|
|
106
|
+
sql: args[1],
|
|
107
|
+
json: args.includes("--json"),
|
|
108
|
+
};
|
|
109
|
+
return queryArgs;
|
|
110
|
+
}
|
|
111
|
+
if (args[0] === "auth") {
|
|
112
|
+
const authArgs = { command: "auth" };
|
|
113
|
+
for (const arg of args) {
|
|
114
|
+
if (arg.startsWith("--client-id=")) {
|
|
115
|
+
authArgs.clientId = arg.slice("--client-id=".length);
|
|
116
|
+
}
|
|
117
|
+
else if (arg.startsWith("--client-secret=")) {
|
|
118
|
+
authArgs.clientSecret = arg.slice("--client-secret=".length);
|
|
119
|
+
}
|
|
120
|
+
else if (arg.startsWith("--code=")) {
|
|
121
|
+
authArgs.code = arg.slice("--code=".length);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return authArgs;
|
|
125
|
+
}
|
|
126
|
+
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
127
|
+
return { command: "help" };
|
|
128
|
+
}
|
|
129
|
+
log.error(`Unknown command: ${args[0]}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
function printHelp() {
|
|
133
|
+
console.log(`
|
|
134
|
+
Claude Coach - Training Plan Tools
|
|
135
|
+
|
|
136
|
+
Usage: npx @robinthues/rt-claude-coach <command> [options]
|
|
137
|
+
|
|
138
|
+
Commands:
|
|
139
|
+
sync Sync activities from Strava
|
|
140
|
+
auth Get Strava authorization URL or exchange code for tokens
|
|
141
|
+
render <file> Render a training plan JSON to HTML
|
|
142
|
+
export-garmin <file> Convert a plan JSON to Garmin sync workouts
|
|
143
|
+
query <sql> Run a SQL query against the database
|
|
144
|
+
help Show this help message
|
|
145
|
+
|
|
146
|
+
Auth Options (for headless/Claude environments):
|
|
147
|
+
--client-id=ID Strava API client ID
|
|
148
|
+
--client-secret=SEC Strava API client secret
|
|
149
|
+
--code=URL_OR_CODE Full redirect URL or just the authorization code
|
|
150
|
+
|
|
151
|
+
Step 1: Run 'auth' with credentials to get authorization URL
|
|
152
|
+
Step 2: User clicks URL, authorizes, copies entire redirect URL
|
|
153
|
+
Step 3: Run 'auth --code=URL' to exchange for tokens
|
|
154
|
+
Step 4: Run 'sync' to fetch activities
|
|
155
|
+
|
|
156
|
+
Sync Options:
|
|
157
|
+
--client-id=ID Strava API client ID (for OAuth flow)
|
|
158
|
+
--client-secret=SEC Strava API client secret (for OAuth flow)
|
|
159
|
+
--days=N Days of history to sync (default: 730)
|
|
160
|
+
|
|
161
|
+
Render Options:
|
|
162
|
+
--output, -o FILE Output HTML file (default: <input>.html)
|
|
163
|
+
|
|
164
|
+
Export Garmin Options:
|
|
165
|
+
--output, -o FILE Output JSON file (default: <input>-garmin-sync.json)
|
|
166
|
+
--tag=TAG Plan tag for workout names/idempotency (default: derived)
|
|
167
|
+
--workout=ID Export only the workout with this id
|
|
168
|
+
--date=YYYY-MM-DD Export only workouts on this date
|
|
169
|
+
|
|
170
|
+
Query Options:
|
|
171
|
+
--json Output as JSON (default: plain text)
|
|
172
|
+
|
|
173
|
+
Examples:
|
|
174
|
+
# Headless auth flow (for Claude/automated environments)
|
|
175
|
+
npx @robinthues/rt-claude-coach auth --client-id=12345 --client-secret=abc123
|
|
176
|
+
# User clicks URL, copies code from failed redirect
|
|
177
|
+
npx @robinthues/rt-claude-coach auth --code=AUTHORIZATION_CODE
|
|
178
|
+
npx @robinthues/rt-claude-coach sync
|
|
179
|
+
|
|
180
|
+
# Interactive auth flow (opens browser)
|
|
181
|
+
npx @robinthues/rt-claude-coach sync --client-id=12345 --client-secret=abc123
|
|
182
|
+
|
|
183
|
+
# Render a training plan to HTML
|
|
184
|
+
npx @robinthues/rt-claude-coach render plan.json --output my-plan.html
|
|
185
|
+
|
|
186
|
+
# Convert a whole plan to Garmin sync workouts
|
|
187
|
+
npx @robinthues/rt-claude-coach export-garmin plan.json --output garmin-sync.json
|
|
188
|
+
|
|
189
|
+
# Convert a single workout, or one day
|
|
190
|
+
npx @robinthues/rt-claude-coach export-garmin plan.json --workout=w1-tue-run
|
|
191
|
+
npx @robinthues/rt-claude-coach export-garmin plan.json --date=2026-09-15
|
|
192
|
+
|
|
193
|
+
# Query the database
|
|
194
|
+
npx @robinthues/rt-claude-coach query "SELECT * FROM weekly_volume LIMIT 5"
|
|
195
|
+
`);
|
|
196
|
+
}
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// Auth Command (for headless/Claude environments)
|
|
199
|
+
// ============================================================================
|
|
200
|
+
const REDIRECT_PORT = 8765;
|
|
201
|
+
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
|
|
202
|
+
const AUTHORIZE_URL = "https://www.strava.com/oauth/authorize";
|
|
203
|
+
const TOKEN_URL = "https://www.strava.com/oauth/token";
|
|
204
|
+
async function runAuth(args) {
|
|
205
|
+
// If code is provided, exchange it for tokens
|
|
206
|
+
if (args.code) {
|
|
207
|
+
if (!configExists()) {
|
|
208
|
+
log.error("No configuration found. Run 'auth' with --client-id and --client-secret first.");
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
// Extract code from full URL if user pasted the entire redirect URL
|
|
212
|
+
let code = args.code;
|
|
213
|
+
if (code.includes("localhost") || code.startsWith("http")) {
|
|
214
|
+
try {
|
|
215
|
+
const url = new URL(code);
|
|
216
|
+
const extractedCode = url.searchParams.get("code");
|
|
217
|
+
if (extractedCode) {
|
|
218
|
+
code = extractedCode;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
log.error("Could not find 'code' parameter in URL");
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Not a valid URL, use as-is
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const config = loadConfig();
|
|
230
|
+
log.start("Exchanging authorization code for tokens...");
|
|
231
|
+
const tokenResponse = await fetch(TOKEN_URL, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "Content-Type": "application/json" },
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
client_id: config.strava.client_id,
|
|
236
|
+
client_secret: config.strava.client_secret,
|
|
237
|
+
code: code,
|
|
238
|
+
grant_type: "authorization_code",
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
if (!tokenResponse.ok) {
|
|
242
|
+
const error = await tokenResponse.text();
|
|
243
|
+
log.error(`Token exchange failed: ${error}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const data = await tokenResponse.json();
|
|
247
|
+
const tokens = {
|
|
248
|
+
access_token: data.access_token,
|
|
249
|
+
refresh_token: data.refresh_token,
|
|
250
|
+
expires_at: data.expires_at,
|
|
251
|
+
athlete_id: data.athlete.id,
|
|
252
|
+
};
|
|
253
|
+
saveTokens(tokens);
|
|
254
|
+
log.success(`Authenticated as ${data.athlete.firstname} ${data.athlete.lastname}`);
|
|
255
|
+
log.ready("Now run: npx @robinthues/rt-claude-coach sync");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Otherwise, generate and print the authorization URL
|
|
259
|
+
if (!args.clientId || !args.clientSecret) {
|
|
260
|
+
log.error("Required: --client-id and --client-secret");
|
|
261
|
+
log.info("Get these from: https://www.strava.com/settings/api");
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
// Save config for later use
|
|
265
|
+
const config = createConfig(args.clientId, args.clientSecret, 730);
|
|
266
|
+
saveConfig(config);
|
|
267
|
+
const authUrl = new URL(AUTHORIZE_URL);
|
|
268
|
+
authUrl.searchParams.set("client_id", args.clientId);
|
|
269
|
+
authUrl.searchParams.set("response_type", "code");
|
|
270
|
+
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
271
|
+
authUrl.searchParams.set("scope", "activity:read_all");
|
|
272
|
+
authUrl.searchParams.set("approval_prompt", "auto");
|
|
273
|
+
console.log("\n📋 AUTHORIZATION URL:\n");
|
|
274
|
+
console.log(authUrl.toString());
|
|
275
|
+
console.log("\n📝 INSTRUCTIONS:");
|
|
276
|
+
console.log("1. Open the URL above in a browser");
|
|
277
|
+
console.log("2. Click 'Authorize' on Strava");
|
|
278
|
+
console.log("3. You'll be redirected to a page that won't load (that's OK!)");
|
|
279
|
+
console.log("4. Copy the ENTIRE URL from your browser's address bar");
|
|
280
|
+
console.log("5. Paste it back to Claude\n");
|
|
281
|
+
}
|
|
282
|
+
// ============================================================================
|
|
283
|
+
// Sync Command
|
|
284
|
+
// ============================================================================
|
|
285
|
+
async function storeActivitiesAndDetails(tokens, activities) {
|
|
286
|
+
log.start("Storing activities in database...");
|
|
287
|
+
let count = 0;
|
|
288
|
+
for (const activity of activities) {
|
|
289
|
+
upsertSummaryActivity(activity);
|
|
290
|
+
count++;
|
|
291
|
+
if (count % 50 === 0) {
|
|
292
|
+
log.progress(` Stored ${count}/${activities.length}...`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
log.progressEnd();
|
|
296
|
+
log.success(`Stored ${activities.length} activities`);
|
|
297
|
+
const details = await syncActivityDetails(tokens);
|
|
298
|
+
const status = details.stopped === "complete" ? "success" : "partial";
|
|
299
|
+
execute(`
|
|
300
|
+
INSERT INTO sync_log (started_at, completed_at, activities_synced, details_synced, status)
|
|
301
|
+
VALUES (datetime('now'), datetime('now'), ${activities.length}, ${details.fetched}, '${status}');
|
|
302
|
+
`);
|
|
303
|
+
log.info(`Database: ${getDbPath()}`);
|
|
304
|
+
if (status === "partial") {
|
|
305
|
+
log.ready("Sync stored all activities; detail backfill resumes on the next sync.");
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
log.ready("Sync complete! You can now create training plans.");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function runSync(args) {
|
|
312
|
+
log.box("Claude Coach - Strava Sync");
|
|
313
|
+
// Step 0: Initialize SQLite backend
|
|
314
|
+
await initDatabase();
|
|
315
|
+
const syncDays = args.days || 730;
|
|
316
|
+
// Step 1: Handle token-based auth (no browser needed)
|
|
317
|
+
if (args.accessToken && args.refreshToken) {
|
|
318
|
+
log.info("Using provided access tokens...");
|
|
319
|
+
// Save tokens - we'll get athlete_id after fetching profile
|
|
320
|
+
// Set expiry to 1 hour from now (we have refresh token for renewal)
|
|
321
|
+
const tempTokens = {
|
|
322
|
+
access_token: args.accessToken,
|
|
323
|
+
refresh_token: args.refreshToken,
|
|
324
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
325
|
+
athlete_id: 0, // Will be updated after fetching athlete
|
|
326
|
+
};
|
|
327
|
+
saveTokens(tempTokens);
|
|
328
|
+
// Create minimal config if needed
|
|
329
|
+
if (!configExists()) {
|
|
330
|
+
// Token-based auth doesn't need client credentials for initial sync
|
|
331
|
+
// but we need them for token refresh - use placeholders
|
|
332
|
+
const config = createConfig("token-auth", "token-auth", syncDays);
|
|
333
|
+
saveConfig(config);
|
|
334
|
+
}
|
|
335
|
+
// Initialize database
|
|
336
|
+
migrate();
|
|
337
|
+
// Fetch athlete to get ID and validate tokens
|
|
338
|
+
log.start("Validating tokens and fetching athlete profile...");
|
|
339
|
+
const athlete = await getAthlete(tempTokens);
|
|
340
|
+
// Update tokens with real athlete ID
|
|
341
|
+
const tokens = { ...tempTokens, athlete_id: athlete.id };
|
|
342
|
+
saveTokens(tokens);
|
|
343
|
+
insertAthlete(athlete);
|
|
344
|
+
log.success(`Authenticated as ${athlete.firstname} ${athlete.lastname}`);
|
|
345
|
+
// Fetch activities
|
|
346
|
+
const afterDate = new Date();
|
|
347
|
+
afterDate.setDate(afterDate.getDate() - syncDays);
|
|
348
|
+
const activities = await getAllActivities(tokens, afterDate);
|
|
349
|
+
await storeActivitiesAndDetails(tokens, activities);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Step 2: OAuth-based auth (requires browser)
|
|
353
|
+
if (!configExists()) {
|
|
354
|
+
if (args.clientId && args.clientSecret) {
|
|
355
|
+
log.info("Creating configuration from command line arguments...");
|
|
356
|
+
const config = createConfig(args.clientId, args.clientSecret, syncDays);
|
|
357
|
+
saveConfig(config);
|
|
358
|
+
log.success("Configuration saved");
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
log.info("No configuration found. Let's set things up.");
|
|
362
|
+
const config = await promptForConfig();
|
|
363
|
+
saveConfig(config);
|
|
364
|
+
log.success("Configuration saved");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const config = loadConfig();
|
|
368
|
+
const configSyncDays = args.days || config.sync_days || 730;
|
|
369
|
+
// Initialize database
|
|
370
|
+
migrate();
|
|
371
|
+
// Authenticate with Strava (opens browser)
|
|
372
|
+
const tokens = await getValidTokens();
|
|
373
|
+
// Step 4: Fetch and store athlete profile
|
|
374
|
+
log.start("Fetching athlete profile...");
|
|
375
|
+
const athlete = await getAthlete(tokens);
|
|
376
|
+
insertAthlete(athlete);
|
|
377
|
+
log.success(`Athlete: ${athlete.firstname} ${athlete.lastname}`);
|
|
378
|
+
// Step 5: Fetch activities
|
|
379
|
+
const afterDate = new Date();
|
|
380
|
+
afterDate.setDate(afterDate.getDate() - configSyncDays);
|
|
381
|
+
const activities = await getAllActivities(tokens, afterDate);
|
|
382
|
+
await storeActivitiesAndDetails(tokens, activities);
|
|
383
|
+
log.ready(`Query with: sqlite3 -json "${getDbPath()}" "SELECT * FROM weekly_volume"`);
|
|
384
|
+
}
|
|
385
|
+
// ============================================================================
|
|
386
|
+
// Render Command
|
|
387
|
+
// ============================================================================
|
|
388
|
+
function getTemplatePath() {
|
|
389
|
+
// Look for template in multiple locations
|
|
390
|
+
const locations = [
|
|
391
|
+
join(__dirname, "..", "templates", "plan-viewer.html"),
|
|
392
|
+
join(__dirname, "..", "..", "templates", "plan-viewer.html"),
|
|
393
|
+
join(process.cwd(), "templates", "plan-viewer.html"),
|
|
394
|
+
];
|
|
395
|
+
for (const loc of locations) {
|
|
396
|
+
try {
|
|
397
|
+
readFileSync(loc);
|
|
398
|
+
return loc;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Continue to next location
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
throw new Error("Could not find plan-viewer.html template");
|
|
405
|
+
}
|
|
406
|
+
function runRender(args) {
|
|
407
|
+
log.start("Rendering training plan...");
|
|
408
|
+
// Read the plan JSON
|
|
409
|
+
let planJson;
|
|
410
|
+
try {
|
|
411
|
+
planJson = readFileSync(args.inputFile, "utf-8");
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
// Validate it's valid JSON
|
|
418
|
+
try {
|
|
419
|
+
JSON.parse(planJson);
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
log.error("Input file is not valid JSON");
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
// Read the template
|
|
426
|
+
const templatePath = getTemplatePath();
|
|
427
|
+
let template = readFileSync(templatePath, "utf-8");
|
|
428
|
+
// Replace the plan data in the template
|
|
429
|
+
const planDataRegex = /<script type="application\/json" id="plan-data">[\s\S]*?<\/script>/;
|
|
430
|
+
const newPlanData = `<script type="application/json" id="plan-data">\n${planJson}\n</script>`;
|
|
431
|
+
template = template.replace(planDataRegex, newPlanData);
|
|
432
|
+
// Output
|
|
433
|
+
if (args.outputFile) {
|
|
434
|
+
writeFileSync(args.outputFile, template);
|
|
435
|
+
log.success(`Training plan rendered to: ${args.outputFile}`);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
// Output to stdout
|
|
439
|
+
console.log(template);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// ============================================================================
|
|
443
|
+
// Export Garmin Command
|
|
444
|
+
// ============================================================================
|
|
445
|
+
function runExportGarmin(args) {
|
|
446
|
+
log.start("Converting plan to Garmin workouts...");
|
|
447
|
+
let planJson;
|
|
448
|
+
try {
|
|
449
|
+
planJson = readFileSync(args.inputFile, "utf-8");
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
let plan;
|
|
456
|
+
try {
|
|
457
|
+
plan = JSON.parse(planJson);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
log.error("Input file is not valid JSON");
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
const filter = {};
|
|
464
|
+
if (args.workoutId !== undefined)
|
|
465
|
+
filter.workoutId = args.workoutId;
|
|
466
|
+
if (args.date !== undefined)
|
|
467
|
+
filter.date = args.date;
|
|
468
|
+
const hasFilter = filter.workoutId !== undefined || filter.date !== undefined;
|
|
469
|
+
let entries;
|
|
470
|
+
try {
|
|
471
|
+
entries = convertPlan(plan, {
|
|
472
|
+
tag: args.tag,
|
|
473
|
+
filter: hasFilter ? filter : undefined,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
log.error(`Conversion failed: ${err.message}`);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
const outputFile = args.outputFile ??
|
|
481
|
+
(args.inputFile.endsWith(".json")
|
|
482
|
+
? args.inputFile.replace(/\.json$/, "-garmin-sync.json")
|
|
483
|
+
: `${args.inputFile}-garmin-sync.json`);
|
|
484
|
+
writeFileSync(outputFile, JSON.stringify(entries, null, 2));
|
|
485
|
+
log.success(`Wrote ${entries.length} Garmin workout entries to: ${outputFile}`);
|
|
486
|
+
}
|
|
487
|
+
// ============================================================================
|
|
488
|
+
// Query Command
|
|
489
|
+
// ============================================================================
|
|
490
|
+
async function runQuery(args) {
|
|
491
|
+
await initDatabase();
|
|
492
|
+
if (args.json) {
|
|
493
|
+
const results = queryJson(args.sql);
|
|
494
|
+
console.log(JSON.stringify(results, null, 2));
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
const result = query(args.sql);
|
|
498
|
+
console.log(result);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// Main
|
|
503
|
+
// ============================================================================
|
|
504
|
+
async function main() {
|
|
505
|
+
const args = parseArgs();
|
|
506
|
+
switch (args.command) {
|
|
507
|
+
case "help":
|
|
508
|
+
printHelp();
|
|
509
|
+
break;
|
|
510
|
+
case "auth":
|
|
511
|
+
await runAuth(args);
|
|
512
|
+
break;
|
|
513
|
+
case "sync":
|
|
514
|
+
await runSync(args);
|
|
515
|
+
break;
|
|
516
|
+
case "render":
|
|
517
|
+
runRender(args);
|
|
518
|
+
break;
|
|
519
|
+
case "export-garmin":
|
|
520
|
+
runExportGarmin(args);
|
|
521
|
+
break;
|
|
522
|
+
case "query":
|
|
523
|
+
await runQuery(args);
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
main().catch((err) => {
|
|
528
|
+
log.error(err.message);
|
|
529
|
+
process.exit(1);
|
|
530
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialize the SQLite backend. Must be called before using other functions.
|
|
3
|
+
*/
|
|
4
|
+
export declare function initDatabase(): Promise<void>;
|
|
5
|
+
export declare function query(sql: string): string;
|
|
6
|
+
export declare function queryJson<T>(sql: string): T[];
|
|
7
|
+
export declare function execute(sql: string): void;
|
|
8
|
+
export declare function runScript(script: string): void;
|