@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.
Files changed (64) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +98 -0
  3. package/bin/claude-coach.js +10 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +530 -0
  6. package/dist/db/client.d.ts +8 -0
  7. package/dist/db/client.js +111 -0
  8. package/dist/db/migrate.d.ts +2 -0
  9. package/dist/db/migrate.js +30 -0
  10. package/dist/db/schema.sql +108 -0
  11. package/dist/garmin/context.d.ts +31 -0
  12. package/dist/garmin/context.js +25 -0
  13. package/dist/garmin/convert.d.ts +28 -0
  14. package/dist/garmin/convert.js +117 -0
  15. package/dist/garmin/dto.d.ts +132 -0
  16. package/dist/garmin/dto.js +35 -0
  17. package/dist/garmin/index.d.ts +3 -0
  18. package/dist/garmin/index.js +1 -0
  19. package/dist/garmin/pace.d.ts +6 -0
  20. package/dist/garmin/pace.js +23 -0
  21. package/dist/garmin/steps.d.ts +12 -0
  22. package/dist/garmin/steps.js +147 -0
  23. package/dist/garmin/targets.d.ts +15 -0
  24. package/dist/garmin/targets.js +85 -0
  25. package/dist/lib/config.d.ts +27 -0
  26. package/dist/lib/config.js +86 -0
  27. package/dist/lib/logging.d.ts +13 -0
  28. package/dist/lib/logging.js +28 -0
  29. package/dist/schema/training-plan.d.ts +288 -0
  30. package/dist/schema/training-plan.js +88 -0
  31. package/dist/strava/api.d.ts +23 -0
  32. package/dist/strava/api.js +88 -0
  33. package/dist/strava/details.d.ts +11 -0
  34. package/dist/strava/details.js +50 -0
  35. package/dist/strava/oauth.d.ts +4 -0
  36. package/dist/strava/oauth.js +113 -0
  37. package/dist/strava/rate-limit.d.ts +9 -0
  38. package/dist/strava/rate-limit.js +16 -0
  39. package/dist/strava/store.d.ts +13 -0
  40. package/dist/strava/store.js +107 -0
  41. package/dist/strava/types.d.ts +49 -0
  42. package/dist/strava/types.js +1 -0
  43. package/dist/viewer/lib/export/erg.d.ts +26 -0
  44. package/dist/viewer/lib/export/erg.js +208 -0
  45. package/dist/viewer/lib/export/fit.d.ts +25 -0
  46. package/dist/viewer/lib/export/fit.js +308 -0
  47. package/dist/viewer/lib/export/ics.d.ts +13 -0
  48. package/dist/viewer/lib/export/ics.js +142 -0
  49. package/dist/viewer/lib/export/index.d.ts +50 -0
  50. package/dist/viewer/lib/export/index.js +229 -0
  51. package/dist/viewer/lib/export/zwo.d.ts +21 -0
  52. package/dist/viewer/lib/export/zwo.js +233 -0
  53. package/dist/viewer/lib/utils.d.ts +14 -0
  54. package/dist/viewer/lib/utils.js +125 -0
  55. package/dist/viewer/main.d.ts +5 -0
  56. package/dist/viewer/main.js +6 -0
  57. package/dist/viewer/stores/changes.d.ts +21 -0
  58. package/dist/viewer/stores/changes.js +49 -0
  59. package/dist/viewer/stores/plan.d.ts +4 -0
  60. package/dist/viewer/stores/plan.js +19 -0
  61. package/dist/viewer/stores/settings.d.ts +53 -0
  62. package/dist/viewer/stores/settings.js +215 -0
  63. package/package.json +67 -0
  64. 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;