claude-coach 0.0.1
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 +84 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +277 -0
- package/dist/db/client.d.ts +4 -0
- package/dist/db/client.js +45 -0
- package/dist/db/migrate.d.ts +1 -0
- package/dist/db/migrate.js +14 -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 +5 -0
- package/dist/strava/api.js +63 -0
- package/dist/strava/oauth.d.ts +4 -0
- package/dist/strava/oauth.js +113 -0
- package/dist/strava/types.d.ts +46 -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 +206 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +307 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +138 -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 +230 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +118 -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 +207 -0
- package/package.json +55 -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,84 @@
|
|
|
1
|
+
# Claude Coach
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
Workouts can be exported as simple calendar events (.ics), Zwift (.zwo), Garmin (.fit), or TrainerRoad/ERG (.mrc) workouts.
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
## Installation & Creating a training plan
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
### Installing the Skill
|
|
16
|
+
|
|
17
|
+
First, [download the latest skill from GitHub Releases](https://github.com/felixrieseberg/claude-coach/releases/latest/download/coach-skill.zip).
|
|
18
|
+
|
|
19
|
+
- If you're using Claude.ai, open the Settings, navigate to Capabilities, then click the `+ Add` button. Upload the `coach-skill.zip` file you just downloaded.
|
|
20
|
+
- If you're using Claude Code, run `/install-skill` and provide the path to the `coach-skill.zip` file you downloaded.
|
|
21
|
+
|
|
22
|
+
### Creating a plan
|
|
23
|
+
|
|
24
|
+
Prompt Claude with something like this:
|
|
25
|
+
|
|
26
|
+
> Help me create a training plan for the Ironman 70.3 Oceanside on March 29th 2026 using the "coach" skill.
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
#### Option 1: Connect to Strava (Recommended)
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
1. Go to [strava.com/settings/api](https://www.strava.com/settings/api) and log in with your Strava account
|
|
37
|
+
2. You'll see a form titled "My API Application" - fill it out:
|
|
38
|
+
- **Application Name**: Enter anything you like (e.g., "Claude Coach")
|
|
39
|
+
- **Category**: Select "Data Importer"
|
|
40
|
+
- **Club**: Leave this blank
|
|
41
|
+
- **Website**: Enter any URL (e.g., `https://claude.ai`)
|
|
42
|
+
- **Application Description**: Enter anything (e.g., "Training plan generation")
|
|
43
|
+
- **Authorization Callback Domain**: Enter `localhost`
|
|
44
|
+
3. Check the box to agree to Strava's API Agreement and click **Create**
|
|
45
|
+
4. Copy your **Client ID** and **Client Secret** and give them to Claude when prompted
|
|
46
|
+
|
|
47
|
+
#### Option 2: Manual Entry
|
|
48
|
+
|
|
49
|
+
Don't use Strava, or prefer not to connect it? No problem. You can tell Claude about your fitness directly. Be prepared to share:
|
|
50
|
+
|
|
51
|
+
**Current Training (recent 4-8 weeks):**
|
|
52
|
+
|
|
53
|
+
- Weekly training hours by sport (swim/bike/run)
|
|
54
|
+
- Typical long session distances (longest ride, longest run, etc.)
|
|
55
|
+
- Training consistency (how many weeks have you been training regularly?)
|
|
56
|
+
|
|
57
|
+
**Performance Benchmarks (any you know):**
|
|
58
|
+
|
|
59
|
+
- Bike FTP (Functional Threshold Power) in watts
|
|
60
|
+
- Run threshold pace or recent race times (5K, 10K, half marathon, etc.)
|
|
61
|
+
- Swim CSS (Critical Swim Speed) or recent time trial (e.g., 1000m time)
|
|
62
|
+
- Max heart rate and/or lactate threshold heart rate
|
|
63
|
+
|
|
64
|
+
### Telling Claude about your event & constraints
|
|
65
|
+
|
|
66
|
+
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:
|
|
67
|
+
|
|
68
|
+
**Training History:**
|
|
69
|
+
|
|
70
|
+
- Years in the sport
|
|
71
|
+
- Previous races completed (distances and approximate times)
|
|
72
|
+
- Any recent breaks from training
|
|
73
|
+
|
|
74
|
+
**Constraints & Considerations:**
|
|
75
|
+
|
|
76
|
+
- Injuries or health issues
|
|
77
|
+
- Schedule limitations (work travel, family, etc.)
|
|
78
|
+
- Equipment access (pool availability, trainer, etc.)
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
# About
|
|
83
|
+
|
|
84
|
+
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,277 @@
|
|
|
1
|
+
import { configExists, loadConfig, promptForConfig, saveConfig, getDbPath, createConfig, } from "./lib/config.js";
|
|
2
|
+
import { log } from "./lib/logging.js";
|
|
3
|
+
import { migrate } from "./db/migrate.js";
|
|
4
|
+
import { execute } from "./db/client.js";
|
|
5
|
+
import { getValidTokens } from "./strava/oauth.js";
|
|
6
|
+
import { getAllActivities, getAthlete } from "./strava/api.js";
|
|
7
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
function parseArgs() {
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
if (args.length === 0 || args[0] === "sync") {
|
|
14
|
+
// Sync command (default)
|
|
15
|
+
const syncArgs = { command: "sync" };
|
|
16
|
+
for (const arg of args) {
|
|
17
|
+
if (arg.startsWith("--client-id=")) {
|
|
18
|
+
syncArgs.clientId = arg.split("=")[1];
|
|
19
|
+
}
|
|
20
|
+
else if (arg.startsWith("--client-secret=")) {
|
|
21
|
+
syncArgs.clientSecret = arg.split("=")[1];
|
|
22
|
+
}
|
|
23
|
+
else if (arg.startsWith("--days=")) {
|
|
24
|
+
syncArgs.days = parseInt(arg.split("=")[1]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return syncArgs;
|
|
28
|
+
}
|
|
29
|
+
if (args[0] === "render") {
|
|
30
|
+
if (!args[1]) {
|
|
31
|
+
log.error("render command requires an input file");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const renderArgs = {
|
|
35
|
+
command: "render",
|
|
36
|
+
inputFile: args[1],
|
|
37
|
+
};
|
|
38
|
+
for (let i = 2; i < args.length; i++) {
|
|
39
|
+
if (args[i] === "--output" || args[i] === "-o") {
|
|
40
|
+
renderArgs.outputFile = args[i + 1];
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
else if (args[i].startsWith("--output=")) {
|
|
44
|
+
renderArgs.outputFile = args[i].split("=")[1];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return renderArgs;
|
|
48
|
+
}
|
|
49
|
+
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
50
|
+
return { command: "help" };
|
|
51
|
+
}
|
|
52
|
+
log.error(`Unknown command: ${args[0]}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
function printHelp() {
|
|
56
|
+
console.log(`
|
|
57
|
+
Claude Coach - Training Plan Tools
|
|
58
|
+
|
|
59
|
+
Usage: npx claude-coach <command> [options]
|
|
60
|
+
|
|
61
|
+
Commands:
|
|
62
|
+
sync Sync activities from Strava (default)
|
|
63
|
+
render <file> Render a training plan JSON to HTML
|
|
64
|
+
help Show this help message
|
|
65
|
+
|
|
66
|
+
Sync Options:
|
|
67
|
+
--client-id=ID Strava API client ID
|
|
68
|
+
--client-secret=SEC Strava API client secret
|
|
69
|
+
--days=N Days of history to sync (default: 730)
|
|
70
|
+
|
|
71
|
+
Render Options:
|
|
72
|
+
--output, -o FILE Output HTML file (default: <input>.html)
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
# Sync from Strava (interactive)
|
|
76
|
+
npx claude-coach
|
|
77
|
+
|
|
78
|
+
# Sync with credentials
|
|
79
|
+
npx claude-coach sync --client-id=12345 --client-secret=abc123
|
|
80
|
+
|
|
81
|
+
# Render a training plan to HTML
|
|
82
|
+
npx claude-coach render plan.json --output my-plan.html
|
|
83
|
+
|
|
84
|
+
# Render to stdout
|
|
85
|
+
npx claude-coach render plan.json
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Sync Command
|
|
90
|
+
// ============================================================================
|
|
91
|
+
function escapeString(str) {
|
|
92
|
+
if (str == null)
|
|
93
|
+
return "NULL";
|
|
94
|
+
return `'${str.replace(/'/g, "''")}'`;
|
|
95
|
+
}
|
|
96
|
+
function insertActivity(activity) {
|
|
97
|
+
const sql = `
|
|
98
|
+
INSERT OR REPLACE INTO activities (
|
|
99
|
+
id, name, sport_type, start_date, elapsed_time, moving_time,
|
|
100
|
+
distance, total_elevation_gain, average_speed, max_speed,
|
|
101
|
+
average_heartrate, max_heartrate, average_watts, max_watts,
|
|
102
|
+
weighted_average_watts, kilojoules, suffer_score, average_cadence,
|
|
103
|
+
calories, description, workout_type, gear_id, raw_json, synced_at
|
|
104
|
+
) VALUES (
|
|
105
|
+
${activity.id},
|
|
106
|
+
${escapeString(activity.name)},
|
|
107
|
+
${escapeString(activity.sport_type)},
|
|
108
|
+
${escapeString(activity.start_date)},
|
|
109
|
+
${activity.elapsed_time ?? "NULL"},
|
|
110
|
+
${activity.moving_time ?? "NULL"},
|
|
111
|
+
${activity.distance ?? "NULL"},
|
|
112
|
+
${activity.total_elevation_gain ?? "NULL"},
|
|
113
|
+
${activity.average_speed ?? "NULL"},
|
|
114
|
+
${activity.max_speed ?? "NULL"},
|
|
115
|
+
${activity.average_heartrate ?? "NULL"},
|
|
116
|
+
${activity.max_heartrate ?? "NULL"},
|
|
117
|
+
${activity.average_watts ?? "NULL"},
|
|
118
|
+
${activity.max_watts ?? "NULL"},
|
|
119
|
+
${activity.weighted_average_watts ?? "NULL"},
|
|
120
|
+
${activity.kilojoules ?? "NULL"},
|
|
121
|
+
${activity.suffer_score ?? "NULL"},
|
|
122
|
+
${activity.average_cadence ?? "NULL"},
|
|
123
|
+
${activity.calories ?? "NULL"},
|
|
124
|
+
${escapeString(activity.description)},
|
|
125
|
+
${activity.workout_type ?? "NULL"},
|
|
126
|
+
${escapeString(activity.gear_id)},
|
|
127
|
+
${escapeString(JSON.stringify(activity))},
|
|
128
|
+
datetime('now')
|
|
129
|
+
);
|
|
130
|
+
`;
|
|
131
|
+
execute(sql);
|
|
132
|
+
}
|
|
133
|
+
function insertAthlete(athlete) {
|
|
134
|
+
const sql = `
|
|
135
|
+
INSERT OR REPLACE INTO athlete (id, firstname, lastname, weight, ftp, raw_json, updated_at)
|
|
136
|
+
VALUES (
|
|
137
|
+
${athlete.id},
|
|
138
|
+
${escapeString(athlete.firstname)},
|
|
139
|
+
${escapeString(athlete.lastname)},
|
|
140
|
+
${athlete.weight ?? "NULL"},
|
|
141
|
+
${athlete.ftp ?? "NULL"},
|
|
142
|
+
${escapeString(JSON.stringify(athlete))},
|
|
143
|
+
datetime('now')
|
|
144
|
+
);
|
|
145
|
+
`;
|
|
146
|
+
execute(sql);
|
|
147
|
+
}
|
|
148
|
+
async function runSync(args) {
|
|
149
|
+
log.box("Claude Coach - Strava Sync");
|
|
150
|
+
// Step 1: Check/create config
|
|
151
|
+
if (!configExists()) {
|
|
152
|
+
if (args.clientId && args.clientSecret) {
|
|
153
|
+
log.info("Creating configuration from command line arguments...");
|
|
154
|
+
const config = createConfig(args.clientId, args.clientSecret, args.days || 730);
|
|
155
|
+
saveConfig(config);
|
|
156
|
+
log.success("Configuration saved");
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
log.info("No configuration found. Let's set things up.");
|
|
160
|
+
const config = await promptForConfig();
|
|
161
|
+
saveConfig(config);
|
|
162
|
+
log.success("Configuration saved");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const config = loadConfig();
|
|
166
|
+
const syncDays = args.days || config.sync_days || 730;
|
|
167
|
+
// Step 2: Initialize database
|
|
168
|
+
migrate();
|
|
169
|
+
// Step 3: Authenticate with Strava
|
|
170
|
+
const tokens = await getValidTokens();
|
|
171
|
+
// Step 4: Fetch and store athlete profile
|
|
172
|
+
log.start("Fetching athlete profile...");
|
|
173
|
+
const athlete = await getAthlete(tokens);
|
|
174
|
+
insertAthlete(athlete);
|
|
175
|
+
log.success(`Athlete: ${athlete.firstname} ${athlete.lastname}`);
|
|
176
|
+
// Step 5: Fetch activities
|
|
177
|
+
const afterDate = new Date();
|
|
178
|
+
afterDate.setDate(afterDate.getDate() - syncDays);
|
|
179
|
+
const activities = await getAllActivities(tokens, afterDate);
|
|
180
|
+
// Step 6: Store activities
|
|
181
|
+
log.start("Storing activities in database...");
|
|
182
|
+
let count = 0;
|
|
183
|
+
for (const activity of activities) {
|
|
184
|
+
insertActivity(activity);
|
|
185
|
+
count++;
|
|
186
|
+
if (count % 50 === 0) {
|
|
187
|
+
log.progress(` Stored ${count}/${activities.length}...`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
log.progressEnd();
|
|
191
|
+
log.success(`Stored ${activities.length} activities`);
|
|
192
|
+
// Step 7: Log sync
|
|
193
|
+
execute(`
|
|
194
|
+
INSERT INTO sync_log (started_at, completed_at, activities_synced, status)
|
|
195
|
+
VALUES (datetime('now'), datetime('now'), ${activities.length}, 'success');
|
|
196
|
+
`);
|
|
197
|
+
log.info(`Database: ${getDbPath()}`);
|
|
198
|
+
log.ready(`Query with: sqlite3 -json "${getDbPath()}" "SELECT * FROM weekly_volume"`);
|
|
199
|
+
}
|
|
200
|
+
// ============================================================================
|
|
201
|
+
// Render Command
|
|
202
|
+
// ============================================================================
|
|
203
|
+
function getTemplatePath() {
|
|
204
|
+
// Look for template in multiple locations
|
|
205
|
+
const locations = [
|
|
206
|
+
join(__dirname, "..", "templates", "plan-viewer.html"),
|
|
207
|
+
join(__dirname, "..", "..", "templates", "plan-viewer.html"),
|
|
208
|
+
join(process.cwd(), "templates", "plan-viewer.html"),
|
|
209
|
+
];
|
|
210
|
+
for (const loc of locations) {
|
|
211
|
+
try {
|
|
212
|
+
readFileSync(loc);
|
|
213
|
+
return loc;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Continue to next location
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
throw new Error("Could not find plan-viewer.html template");
|
|
220
|
+
}
|
|
221
|
+
function runRender(args) {
|
|
222
|
+
log.start("Rendering training plan...");
|
|
223
|
+
// Read the plan JSON
|
|
224
|
+
let planJson;
|
|
225
|
+
try {
|
|
226
|
+
planJson = readFileSync(args.inputFile, "utf-8");
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
// Validate it's valid JSON
|
|
233
|
+
try {
|
|
234
|
+
JSON.parse(planJson);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
log.error("Input file is not valid JSON");
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
// Read the template
|
|
241
|
+
const templatePath = getTemplatePath();
|
|
242
|
+
let template = readFileSync(templatePath, "utf-8");
|
|
243
|
+
// Replace the plan data in the template
|
|
244
|
+
const planDataRegex = /<script type="application\/json" id="plan-data">[\s\S]*?<\/script>/;
|
|
245
|
+
const newPlanData = `<script type="application/json" id="plan-data">\n${planJson}\n</script>`;
|
|
246
|
+
template = template.replace(planDataRegex, newPlanData);
|
|
247
|
+
// Output
|
|
248
|
+
if (args.outputFile) {
|
|
249
|
+
writeFileSync(args.outputFile, template);
|
|
250
|
+
log.success(`Training plan rendered to: ${args.outputFile}`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// Output to stdout
|
|
254
|
+
console.log(template);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// Main
|
|
259
|
+
// ============================================================================
|
|
260
|
+
async function main() {
|
|
261
|
+
const args = parseArgs();
|
|
262
|
+
switch (args.command) {
|
|
263
|
+
case "help":
|
|
264
|
+
printHelp();
|
|
265
|
+
break;
|
|
266
|
+
case "sync":
|
|
267
|
+
await runSync(args);
|
|
268
|
+
break;
|
|
269
|
+
case "render":
|
|
270
|
+
runRender(args);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
main().catch((err) => {
|
|
275
|
+
log.error(err.message);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execSync, spawnSync } from "child_process";
|
|
2
|
+
import { getDbPath } from "../lib/config.js";
|
|
3
|
+
export function query(sql) {
|
|
4
|
+
const dbPath = getDbPath();
|
|
5
|
+
try {
|
|
6
|
+
return execSync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, {
|
|
7
|
+
encoding: "utf-8",
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const err = error;
|
|
12
|
+
throw new Error(`SQLite error: ${err.stderr || err.message}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function queryJson(sql) {
|
|
16
|
+
const dbPath = getDbPath();
|
|
17
|
+
try {
|
|
18
|
+
const result = execSync(`sqlite3 -json "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, {
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
});
|
|
21
|
+
if (!result.trim())
|
|
22
|
+
return [];
|
|
23
|
+
return JSON.parse(result);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const err = error;
|
|
27
|
+
throw new Error(`SQLite error: ${err.stderr || err.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function execute(sql) {
|
|
31
|
+
const dbPath = getDbPath();
|
|
32
|
+
const result = spawnSync("sqlite3", [dbPath], {
|
|
33
|
+
input: sql,
|
|
34
|
+
encoding: "utf-8",
|
|
35
|
+
});
|
|
36
|
+
if (result.error) {
|
|
37
|
+
throw result.error;
|
|
38
|
+
}
|
|
39
|
+
if (result.status !== 0) {
|
|
40
|
+
throw new Error(`SQLite error: ${result.stderr}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function runScript(script) {
|
|
44
|
+
execute(script);
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function migrate(): void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { runScript } from "./client.js";
|
|
5
|
+
import { ensureConfigDir } from "../lib/config.js";
|
|
6
|
+
import { log } from "../lib/logging.js";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export function migrate() {
|
|
9
|
+
ensureConfigDir();
|
|
10
|
+
const schemaPath = join(__dirname, "schema.sql");
|
|
11
|
+
const schema = readFileSync(schemaPath, "utf-8");
|
|
12
|
+
runScript(schema);
|
|
13
|
+
log.success("Database schema initialized");
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface StravaConfig {
|
|
2
|
+
client_id: string;
|
|
3
|
+
client_secret: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Config {
|
|
6
|
+
strava: StravaConfig;
|
|
7
|
+
sync_days: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Tokens {
|
|
10
|
+
access_token: string;
|
|
11
|
+
refresh_token: string;
|
|
12
|
+
expires_at: number;
|
|
13
|
+
athlete_id: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function ensureConfigDir(): void;
|
|
16
|
+
export declare function getConfigPath(): string;
|
|
17
|
+
export declare function getTokensPath(): string;
|
|
18
|
+
export declare function getDbPath(): string;
|
|
19
|
+
export declare function configExists(): boolean;
|
|
20
|
+
export declare function tokensExist(): boolean;
|
|
21
|
+
export declare function loadConfig(): Config;
|
|
22
|
+
export declare function saveConfig(config: Config): void;
|
|
23
|
+
export declare function loadTokens(): Tokens;
|
|
24
|
+
export declare function saveTokens(tokens: Tokens): void;
|
|
25
|
+
export declare function tokensExpired(tokens: Tokens): boolean;
|
|
26
|
+
export declare function promptForConfig(): Promise<Config>;
|
|
27
|
+
export declare function createConfig(client_id: string, client_secret: string, sync_days?: number): Config;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import * as readline from "readline";
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".claude-coach");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
+
const TOKENS_FILE = join(CONFIG_DIR, "tokens.json");
|
|
8
|
+
const DB_FILE = join(CONFIG_DIR, "coach.db");
|
|
9
|
+
export function ensureConfigDir() {
|
|
10
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
11
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function getConfigPath() {
|
|
15
|
+
return CONFIG_FILE;
|
|
16
|
+
}
|
|
17
|
+
export function getTokensPath() {
|
|
18
|
+
return TOKENS_FILE;
|
|
19
|
+
}
|
|
20
|
+
export function getDbPath() {
|
|
21
|
+
return DB_FILE;
|
|
22
|
+
}
|
|
23
|
+
export function configExists() {
|
|
24
|
+
return existsSync(CONFIG_FILE);
|
|
25
|
+
}
|
|
26
|
+
export function tokensExist() {
|
|
27
|
+
return existsSync(TOKENS_FILE);
|
|
28
|
+
}
|
|
29
|
+
export function loadConfig() {
|
|
30
|
+
if (!configExists()) {
|
|
31
|
+
throw new Error(`Config not found at ${CONFIG_FILE}. Run setup first.`);
|
|
32
|
+
}
|
|
33
|
+
const data = readFileSync(CONFIG_FILE, "utf-8");
|
|
34
|
+
return JSON.parse(data);
|
|
35
|
+
}
|
|
36
|
+
export function saveConfig(config) {
|
|
37
|
+
ensureConfigDir();
|
|
38
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
39
|
+
}
|
|
40
|
+
export function loadTokens() {
|
|
41
|
+
if (!tokensExist()) {
|
|
42
|
+
throw new Error(`Tokens not found at ${TOKENS_FILE}. Run auth first.`);
|
|
43
|
+
}
|
|
44
|
+
const data = readFileSync(TOKENS_FILE, "utf-8");
|
|
45
|
+
return JSON.parse(data);
|
|
46
|
+
}
|
|
47
|
+
export function saveTokens(tokens) {
|
|
48
|
+
ensureConfigDir();
|
|
49
|
+
writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
|
|
50
|
+
}
|
|
51
|
+
export function tokensExpired(tokens) {
|
|
52
|
+
// Add 60 second buffer
|
|
53
|
+
return Date.now() / 1000 > tokens.expires_at - 60;
|
|
54
|
+
}
|
|
55
|
+
async function prompt(question) {
|
|
56
|
+
const rl = readline.createInterface({
|
|
57
|
+
input: process.stdin,
|
|
58
|
+
output: process.stdout,
|
|
59
|
+
});
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
rl.question(question, (answer) => {
|
|
62
|
+
rl.close();
|
|
63
|
+
resolve(answer.trim());
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function promptForConfig() {
|
|
68
|
+
console.log("\n🚴 Claude Coach Setup\n");
|
|
69
|
+
console.log("To use this tool, you need a Strava API application.");
|
|
70
|
+
console.log("Create one at: https://www.strava.com/settings/api");
|
|
71
|
+
console.log('Set "Authorization Callback Domain" to: localhost\n');
|
|
72
|
+
const client_id = await prompt("Enter your Strava Client ID: ");
|
|
73
|
+
const client_secret = await prompt("Enter your Strava Client Secret: ");
|
|
74
|
+
const sync_days_str = await prompt("Days of history to sync (default 730): ");
|
|
75
|
+
const sync_days = parseInt(sync_days_str) || 730;
|
|
76
|
+
return {
|
|
77
|
+
strava: { client_id, client_secret },
|
|
78
|
+
sync_days,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function createConfig(client_id, client_secret, sync_days = 730) {
|
|
82
|
+
return {
|
|
83
|
+
strava: { client_id, client_secret },
|
|
84
|
+
sync_days,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const log: {
|
|
2
|
+
info: (message: string, ...args: unknown[]) => void;
|
|
3
|
+
success: (message: string, ...args: unknown[]) => void;
|
|
4
|
+
warn: (message: string, ...args: unknown[]) => void;
|
|
5
|
+
error: (message: string, ...args: unknown[]) => void;
|
|
6
|
+
debug: (message: string, ...args: unknown[]) => void;
|
|
7
|
+
box: (message: string) => void;
|
|
8
|
+
start: (message: string) => void;
|
|
9
|
+
ready: (message: string) => void;
|
|
10
|
+
progress: (message: string) => void;
|
|
11
|
+
progressEnd: () => void;
|
|
12
|
+
};
|
|
13
|
+
export type Logger = typeof log;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { consola } from "consola";
|
|
2
|
+
// Configure consola with pretty formatting
|
|
3
|
+
const logger = consola.create({
|
|
4
|
+
level: process.env.LOG_LEVEL === "debug" ? 4 : 3,
|
|
5
|
+
formatOptions: {
|
|
6
|
+
date: false,
|
|
7
|
+
colors: true,
|
|
8
|
+
compact: false,
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
export const log = {
|
|
12
|
+
info: (message, ...args) => logger.info(message, ...args),
|
|
13
|
+
success: (message, ...args) => logger.success(message, ...args),
|
|
14
|
+
warn: (message, ...args) => logger.warn(message, ...args),
|
|
15
|
+
error: (message, ...args) => logger.error(message, ...args),
|
|
16
|
+
debug: (message, ...args) => logger.debug(message, ...args),
|
|
17
|
+
box: (message) => logger.box(message),
|
|
18
|
+
start: (message) => logger.start(message),
|
|
19
|
+
ready: (message) => logger.ready(message),
|
|
20
|
+
// Progress-style logging (overwrites current line)
|
|
21
|
+
progress: (message) => {
|
|
22
|
+
process.stdout.write(`\r${message}`);
|
|
23
|
+
},
|
|
24
|
+
// End progress line
|
|
25
|
+
progressEnd: () => {
|
|
26
|
+
process.stdout.write("\n");
|
|
27
|
+
},
|
|
28
|
+
};
|