ccwrap 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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +181 -0
  5. package/dist/data/commentary.d.ts +13 -0
  6. package/dist/data/commentary.js +102 -0
  7. package/dist/data/parser.d.ts +2 -0
  8. package/dist/data/parser.js +358 -0
  9. package/dist/data/types.d.ts +109 -0
  10. package/dist/data/types.js +1 -0
  11. package/dist/render.d.ts +2 -0
  12. package/dist/render.js +60 -0
  13. package/dist/video/Composition.d.ts +8 -0
  14. package/dist/video/Composition.js +50 -0
  15. package/dist/video/Root.d.ts +2 -0
  16. package/dist/video/Root.js +81 -0
  17. package/dist/video/components/AnimatedNumber.d.ts +10 -0
  18. package/dist/video/components/AnimatedNumber.js +16 -0
  19. package/dist/video/components/FadeIn.d.ts +8 -0
  20. package/dist/video/components/FadeIn.js +18 -0
  21. package/dist/video/components/GlowOrb.d.ts +8 -0
  22. package/dist/video/components/GlowOrb.js +19 -0
  23. package/dist/video/components/ParticleField.d.ts +5 -0
  24. package/dist/video/components/ParticleField.js +36 -0
  25. package/dist/video/index.d.ts +1 -0
  26. package/dist/video/index.js +3 -0
  27. package/dist/video/slides/ArchetypeSlide.d.ts +7 -0
  28. package/dist/video/slides/ArchetypeSlide.js +34 -0
  29. package/dist/video/slides/BusiestDaySlide.d.ts +7 -0
  30. package/dist/video/slides/BusiestDaySlide.js +23 -0
  31. package/dist/video/slides/CostSlide.d.ts +7 -0
  32. package/dist/video/slides/CostSlide.js +12 -0
  33. package/dist/video/slides/IntroSlide.d.ts +5 -0
  34. package/dist/video/slides/IntroSlide.js +21 -0
  35. package/dist/video/slides/ModelSlide.d.ts +7 -0
  36. package/dist/video/slides/ModelSlide.js +45 -0
  37. package/dist/video/slides/PeakHoursSlide.d.ts +7 -0
  38. package/dist/video/slides/PeakHoursSlide.js +48 -0
  39. package/dist/video/slides/SessionSlide.d.ts +7 -0
  40. package/dist/video/slides/SessionSlide.js +22 -0
  41. package/dist/video/slides/SummarySlide.d.ts +7 -0
  42. package/dist/video/slides/SummarySlide.js +52 -0
  43. package/dist/video/slides/TokensSlide.d.ts +7 -0
  44. package/dist/video/slides/TokensSlide.js +25 -0
  45. package/dist/video/styles.d.ts +45 -0
  46. package/dist/video/styles.js +84 -0
  47. package/package.json +58 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ccwrapped contributors
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,99 @@
1
+ # ccwrap
2
+
3
+ Your Claude Code usage stats, meme-ified into a shareable video. Like Spotify Wrapped, but for your AI coding assistant.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npx ccwrap
9
+ ```
10
+
11
+ Or install globally:
12
+
13
+ ```bash
14
+ npm install -g ccwrap
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ # Last month (default)
21
+ ccwrap
22
+
23
+ # Last week
24
+ ccwrap --period week
25
+
26
+ # Last quarter
27
+ ccwrap --period quarter
28
+
29
+ # All time
30
+ ccwrap --period all
31
+
32
+ # Custom date range
33
+ ccwrap --since 2025-06-01
34
+ ccwrap --since 2025-06-01 --until 2025-12-31
35
+
36
+ # Dark mode - environmental impact commentary
37
+ ccwrap --mode dark
38
+
39
+ # Custom output path
40
+ ccwrap -o my-wrapped.mp4
41
+
42
+ # Just print stats, no video
43
+ ccwrap --stats-only
44
+
45
+ # Output stats as JSON
46
+ ccwrap --json
47
+ ```
48
+
49
+ ## Time Periods
50
+
51
+ By default, ccwrap recaps the **last 30 days**. Use `--period` for presets or `--since`/`--until` for custom ranges.
52
+
53
+ | Flag | Period |
54
+ |---|---|
55
+ | `--period week` | Last 7 days |
56
+ | `--period month` | Last 30 days (default) |
57
+ | `--period quarter` | Last 90 days |
58
+ | `--period all` | All time |
59
+ | `--since YYYY-MM-DD` | Custom start date (until today) |
60
+ | `--since ... --until ...` | Custom date range |
61
+
62
+ ## AI Commentary
63
+
64
+ Set `ANTHROPIC_API_KEY` to get AI-generated personalized commentary on each slide. Without it, pre-written fallback quips are used.
65
+
66
+ ```bash
67
+ export ANTHROPIC_API_KEY=sk-ant-...
68
+ ccwrap
69
+ ```
70
+
71
+ ## Modes
72
+
73
+ | Sassy (default) | Dark (`--mode dark`) |
74
+ |---|---|
75
+ | Purple/cyan neon aesthetic | Blood red/stone aesthetic |
76
+ | Meme humor ("that's 50 coffees") | Environmental impact ("4.5L of water used") |
77
+ | Affectionate roasting | Uncomfortable truths, but funny |
78
+
79
+ ## Video Slides
80
+
81
+ 1. **Intro** - Claude Code Wrapped with period-aware tagline
82
+ 2. **Tokens** - Total tokens with counting animation
83
+ 3. **Cost** - Dollar amount + fun equivalents
84
+ 4. **Model** - Favorite model reveal + usage breakdown
85
+ 5. **Busiest Day** - Peak day with activity chart
86
+ 6. **Sessions** - Marathon stats, streaks, scores
87
+ 7. **Peak Hours** - 24h activity histogram
88
+ 8. **Archetype** - Your coding personality
89
+ 9. **Summary** - Shareable card with all stats
90
+
91
+ ## Requirements
92
+
93
+ - Node.js >= 18
94
+ - Chrome/Chromium (auto-downloaded by Remotion if not found)
95
+ - Claude Code usage data in `~/.claude/projects/`
96
+
97
+ ## License
98
+
99
+ [MIT](LICENSE)
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ import { program } from "commander";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import { loadAndComputeStats } from "./data/parser.js";
6
+ import { generateCommentary } from "./data/commentary.js";
7
+ import { renderVideo } from "./render.js";
8
+ function resolveDateRange(options) {
9
+ const now = new Date();
10
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
11
+ if (options.since && options.period) {
12
+ console.error(chalk.red("Error: --period and --since are mutually exclusive. Use one or the other."));
13
+ process.exit(1);
14
+ }
15
+ // Default --period to "month" when neither --period nor --since is provided
16
+ if (!options.period && !options.since) {
17
+ options.period = "month";
18
+ }
19
+ if (options.until && !options.since) {
20
+ console.error(chalk.red("Error: --until requires --since."));
21
+ process.exit(1);
22
+ }
23
+ // Custom date range
24
+ if (options.since) {
25
+ const since = new Date(options.since + "T00:00:00");
26
+ if (isNaN(since.getTime())) {
27
+ console.error(chalk.red(`Error: Invalid --since date "${options.since}". Use YYYY-MM-DD format.`));
28
+ process.exit(1);
29
+ }
30
+ let until = today;
31
+ if (options.until) {
32
+ until = new Date(options.until + "T23:59:59.999");
33
+ if (isNaN(until.getTime())) {
34
+ console.error(chalk.red(`Error: Invalid --until date "${options.until}". Use YYYY-MM-DD format.`));
35
+ process.exit(1);
36
+ }
37
+ }
38
+ // Clamp future dates
39
+ if (until > today)
40
+ until = today;
41
+ if (since > today) {
42
+ console.error(chalk.red("Error: --since date is in the future."));
43
+ process.exit(1);
44
+ }
45
+ if (since > until) {
46
+ console.error(chalk.red("Error: --since date is after --until date."));
47
+ process.exit(1);
48
+ }
49
+ const fmtOpts = { month: "short", day: "numeric" };
50
+ const sinceStr = since.toLocaleDateString("en-US", fmtOpts);
51
+ const untilYear = until.getFullYear();
52
+ const sinceYear = since.getFullYear();
53
+ const untilStr = until.toLocaleDateString("en-US", { ...fmtOpts, year: sinceYear !== untilYear ? "numeric" : undefined });
54
+ const periodLabel = `${sinceStr} – ${untilStr}, ${untilYear}`;
55
+ return { range: { since, until }, periodLabel };
56
+ }
57
+ // Preset period
58
+ const period = (["week", "month", "quarter", "all"].includes(options.period ?? "") ? options.period : "month");
59
+ if (period === "all") {
60
+ return { range: undefined, periodLabel: "Your all-time" };
61
+ }
62
+ const daysBack = period === "week" ? 7 : period === "quarter" ? 90 : 30;
63
+ const since = new Date(today);
64
+ since.setDate(since.getDate() - daysBack);
65
+ since.setHours(0, 0, 0, 0);
66
+ const labels = {
67
+ week: "Your week",
68
+ month: "Your month",
69
+ quarter: "Your quarter",
70
+ };
71
+ return { range: { since, until: today }, periodLabel: labels[period] };
72
+ }
73
+ const banner = `
74
+ ██████╗ ██████╗██╗ ██╗██████╗ █████╗ ██████╗ ██████╗ ███████╗██████╗
75
+ ██╔════╝██╔════╝██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗
76
+ ██║ ██║ ██║ █╗ ██║██████╔╝███████║██████╔╝██████╔╝█████╗ ██║ ██║
77
+ ██║ ██║ ██║███╗██║██╔══██╗██╔══██║██╔═══╝ ██╔═══╝ ██╔══╝ ██║ ██║
78
+ ╚██████╗╚██████╗╚███╔███╔╝██║ ██║██║ ██║██║ ██║ ███████╗██████╔╝
79
+ ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═════╝
80
+ `;
81
+ const darkBanner = `
82
+ ██████╗ ██████╗██╗ ██╗██████╗ █████╗ ██████╗ ██████╗ ███████╗██████╗
83
+ ██╔════╝██╔════╝██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗
84
+ ██║ ██║ ██║ █╗ ██║██████╔╝███████║██████╔╝██████╔╝█████╗ ██║ ██║
85
+ ██║ ██║ ██║███╗██║██╔══██╗██╔══██║██╔═══╝ ██╔═══╝ ██╔══╝ ██║ ██║
86
+ ╚██████╗╚██████╗╚███╔███╔╝██║ ██║██║ ██║██║ ██║ ███████╗██████╔╝
87
+ ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═════╝
88
+ `;
89
+ program
90
+ .name("ccwrap")
91
+ .description("Claude Code Wrapped - Your AI coding stats, meme-ified into a shareable video")
92
+ .version("0.1.0")
93
+ .option("-o, --output <path>", "Output video file path", "ccwrapped.mp4")
94
+ .option("-m, --mode <mode>", "Commentary mode: sassy or dark", "sassy")
95
+ .option("-p, --period <period>", "Time period: week, month, quarter, all (default: month)")
96
+ .option("--since <date>", "Start date (YYYY-MM-DD), mutually exclusive with --period")
97
+ .option("--until <date>", "End date (YYYY-MM-DD), requires --since")
98
+ .option("--json", "Output stats as JSON instead of video")
99
+ .option("--stats-only", "Print stats to console without rendering video")
100
+ .action(async (options) => {
101
+ const mode = options.mode === "dark" ? "dark" : "sassy";
102
+ const isDark = mode === "dark";
103
+ console.log(isDark ? chalk.red(darkBanner) : chalk.hex("#7c3aed")(banner));
104
+ console.log(chalk.gray(isDark
105
+ ? " Your AI usage. The planet remembers.\n"
106
+ : " Your AI-assisted coding, recapped.\n"));
107
+ // Resolve date range
108
+ const { range, periodLabel } = resolveDateRange(options);
109
+ // Step 1: Load and compute stats
110
+ const periodHint = range
111
+ ? chalk.gray(` (${periodLabel})`)
112
+ : "";
113
+ const spinner = ora({ text: `Scanning Claude Code usage data...${periodHint}`, color: isDark ? "red" : "magenta" }).start();
114
+ let stats;
115
+ try {
116
+ stats = await loadAndComputeStats(range, periodLabel);
117
+ spinner.succeed(chalk.green("Usage data loaded!"));
118
+ }
119
+ catch (err) {
120
+ spinner.fail(chalk.red(`Failed to load data: ${err.message}`));
121
+ process.exit(1);
122
+ }
123
+ // Print summary
124
+ console.log();
125
+ const h = isDark ? chalk.red : chalk.hex("#7c3aed");
126
+ console.log(h("═══ Your Stats ═══"));
127
+ console.log(chalk.white(` Tokens: ${chalk.hex("#f59e0b").bold(stats.totalTokens.toLocaleString())}`));
128
+ console.log(chalk.white(` Cost: ${chalk.hex("#10b981").bold(`$${stats.totalCost.toFixed(2)}`)}`));
129
+ console.log(chalk.white(` Sessions: ${chalk.hex("#7c3aed").bold(stats.totalSessions.toString())}`));
130
+ console.log(chalk.white(` Messages: ${chalk.hex("#ec4899").bold(stats.totalMessages.toLocaleString())}`));
131
+ console.log(chalk.white(` Days Active: ${chalk.hex("#06b6d4").bold(stats.totalDaysActive.toString())}`));
132
+ console.log(chalk.white(` Fav Model: ${chalk.hex("#f43f5e").bold(stats.favoriteModel)}`));
133
+ console.log(chalk.white(` Archetype: ${h.bold(stats.archetype)}`));
134
+ if (isDark) {
135
+ console.log(chalk.white(` Water: ${chalk.hex("#06b6d4").bold(`${stats.waterLiters.toFixed(1)}L`)}`));
136
+ console.log(chalk.white(` CO₂: ${chalk.gray.bold(`${(stats.co2Grams / 1000).toFixed(1)}kg`)}`));
137
+ console.log(chalk.white(` Energy: ${chalk.hex("#f59e0b").bold(`${stats.kwhUsed.toFixed(1)} kWh`)}`));
138
+ }
139
+ console.log(chalk.gray(` "${stats.archetypeDescription}"`));
140
+ console.log();
141
+ if (options.json) {
142
+ console.log(JSON.stringify(stats, null, 2));
143
+ return;
144
+ }
145
+ if (options.statsOnly) {
146
+ return;
147
+ }
148
+ // Step 2: Generate AI commentary
149
+ const commentarySpinner = ora({
150
+ text: isDark ? "Generating dark commentary..." : "Generating sassy commentary...",
151
+ color: isDark ? "red" : "magenta",
152
+ }).start();
153
+ let commentary;
154
+ try {
155
+ commentary = await generateCommentary(stats, mode);
156
+ commentarySpinner.succeed(chalk.green("Commentary generated!"));
157
+ }
158
+ catch (err) {
159
+ commentarySpinner.fail(chalk.yellow(`Commentary generation failed, using fallbacks`));
160
+ // Fallbacks are handled inside generateCommentary
161
+ commentary = await generateCommentary(stats, mode);
162
+ }
163
+ // Step 3: Render video
164
+ const renderSpinner = ora({ text: "Rendering your Wrapped video...", color: isDark ? "red" : "magenta" }).start();
165
+ try {
166
+ const outputPath = await renderVideo(stats, commentary, mode, options.output);
167
+ renderSpinner.succeed(chalk.green(`Video rendered!`));
168
+ console.log();
169
+ console.log(h(` 🎬 ${chalk.white.bold(outputPath)}`));
170
+ console.log(chalk.gray(isDark
171
+ ? " Share it and make your coworkers contemplate their carbon footprint."
172
+ : " Share it in Slack and flex on your coworkers."));
173
+ console.log();
174
+ }
175
+ catch (err) {
176
+ renderSpinner.fail(chalk.red(`Render failed: ${err.message}`));
177
+ console.error(err);
178
+ process.exit(1);
179
+ }
180
+ });
181
+ program.parse();
@@ -0,0 +1,13 @@
1
+ import type { WrappedStats } from "./types";
2
+ export interface Commentary {
3
+ tokensLine: string;
4
+ costLine: string;
5
+ modelLine: string;
6
+ busiestDayLine: string;
7
+ sessionLine: string;
8
+ peakHoursLine: string;
9
+ archetypeLine: string;
10
+ summaryLine: string;
11
+ }
12
+ export type Mode = "sassy" | "dark";
13
+ export declare function generateCommentary(stats: WrappedStats, mode: Mode): Promise<Commentary>;
@@ -0,0 +1,102 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ function buildPrompt(stats, mode) {
3
+ const modeDescription = mode === "dark"
4
+ ? `DARK MODE: Your commentary should be darkly humorous with environmental/existential undertones.
5
+ Think: water usage, carbon footprint, e-waste, heat death of the universe, humanity's dependence on AI.
6
+ Examples of the vibe:
7
+ - "That's 100 gallons of water gone. We're not finding Nemo now."
8
+ - "Those tokens generated enough heat to melt a small glacier. You're welcome, polar bears."
9
+ - "You spent $X talking to a machine. Your therapist charges less and at least pretends to care."
10
+ - "Your longest session was 8 hours. The data center fans were begging for mercy."
11
+ Make it funny but with that uncomfortable kernel of truth. Dark comedy, not depressing.`
12
+ : `SASSY MODE: Your commentary should be hilariously sassy, roasty, and meme-culture aware.
13
+ Think: unhinged twitter energy, gen-z humor, affectionate bullying.
14
+ Examples of the vibe:
15
+ - "Bestie you spent more on tokens than my monthly grocery bill. Touch grass."
16
+ - "Opus? Oh we got a trust fund baby over here."
17
+ - "You coded at 3am on a Tuesday. That's not dedication, that's a cry for help."
18
+ Make it genuinely funny. Internet humor. The kind of thing people screenshot and share.`;
19
+ const periodContext = stats.periodLabel.startsWith("Your ")
20
+ ? `This recap covers ${stats.periodLabel.toLowerCase().replace("your ", "the user's last ")} of usage.`
21
+ : `This recap covers the period ${stats.periodLabel}.`;
22
+ return `You are writing short, punchy one-liner commentary for a "Claude Code Wrapped" recap video — a shareable stats recap of someone's AI coding assistant usage. Each line appears as a caption on an animated slide.
23
+
24
+ ${periodContext}
25
+
26
+ ${modeDescription}
27
+
28
+ Here are the user's stats:
29
+ - Total tokens: ${stats.totalTokens.toLocaleString()} (${stats.warAndPeaceEquivalent.toFixed(1)}x War and Peace)
30
+ - Total estimated cost: $${stats.totalCost.toFixed(2)} (${stats.coffeeEquivalent} coffees worth)
31
+ - Favorite model: ${stats.favoriteModel}
32
+ - Busiest day: ${stats.busiestDay.date} with ${stats.busiestDay.tokenCount.toLocaleString()} tokens and ${stats.busiestDay.messageCount} messages
33
+ - Longest session: ${Math.round(stats.longestSession.durationMinutes)} minutes (${(stats.longestSession.durationMinutes / 60).toFixed(1)} hours)
34
+ - Total sessions: ${stats.totalSessions}
35
+ - Total messages: ${stats.totalMessages.toLocaleString()}
36
+ - Active days: ${stats.totalDaysActive}
37
+ - Peak coding hour: ${stats.busiestHour}:00
38
+ - Night owl score: ${stats.nightOwlScore}% (% of tokens during 10pm-5am)
39
+ - Weekend warrior score: ${stats.weekendWarriorScore}%
40
+ - Longest streak: ${stats.streakDays} consecutive days
41
+ - Archetype: "${stats.archetype}" - ${stats.archetypeDescription}
42
+
43
+ Generate exactly 8 one-liner captions (max 120 chars each). They should be FUNNY and SHAREABLE.
44
+ Output as JSON with these exact keys: tokensLine, costLine, modelLine, busiestDayLine, sessionLine, peakHoursLine, archetypeLine, summaryLine
45
+
46
+ Return ONLY the JSON object, no markdown fences or other text.`;
47
+ }
48
+ // Hardcoded dark mode fallbacks
49
+ const darkFallbacks = {
50
+ tokensLine: "Each token consumed water, power, and a tiny piece of the planet. You're welcome, Earth.",
51
+ costLine: "You paid for the privilege of accelerating compute demand. The grid felt that.",
52
+ modelLine: "You picked the model. The data center picked up the energy bill.",
53
+ busiestDayLine: "Your biggest day kept servers spinning hot enough to heat a small apartment.",
54
+ sessionLine: "That session burned more electricity than your fridge uses in a week. Priorities.",
55
+ peakHoursLine: "Late-night prompts mean late-night cooling systems. The night shift thanks you.",
56
+ archetypeLine: "Every archetype is just a fun way to say 'heavy compute consumer.' But sure, it's personality.",
57
+ summaryLine: "You coded with AI. The ice caps continued to melt. Coincidence? Definitely. Probably.",
58
+ };
59
+ // Hardcoded sassy fallbacks
60
+ const sassyFallbacks = {
61
+ tokensLine: "That's a LOT of tokens bestie. Your AI dependency is showing.",
62
+ costLine: "You could've bought something nice. Instead you bought robot words. No regrets though.",
63
+ modelLine: "Your model choice says a lot about you. We won't say what, but it says A LOT.",
64
+ busiestDayLine: "Your biggest day was UNHINGED. The vibes were immaculate, the productivity was concerning.",
65
+ sessionLine: "That marathon session? Iconic. Unhealthy, but iconic.",
66
+ peakHoursLine: "Your peak hours reveal things about your sleep schedule that we're choosing not to address.",
67
+ archetypeLine: "This archetype was chosen for you by math. The math doesn't lie. You might not like the math.",
68
+ summaryLine: "Outsourcing your thinking at scale. Honestly? Respect. Keep going.",
69
+ };
70
+ export async function generateCommentary(stats, mode) {
71
+ const apiKey = process.env.ANTHROPIC_API_KEY;
72
+ if (!apiKey) {
73
+ console.log(" No ANTHROPIC_API_KEY found, using pre-written commentary.");
74
+ return mode === "dark" ? darkFallbacks : sassyFallbacks;
75
+ }
76
+ try {
77
+ const client = new Anthropic({ apiKey });
78
+ const message = await client.messages.create({
79
+ model: "claude-haiku-4-5-20251001",
80
+ max_tokens: 1024,
81
+ messages: [{ role: "user", content: buildPrompt(stats, mode) }],
82
+ });
83
+ const text = message.content[0]?.type === "text" ? message.content[0].text : "";
84
+ const parsed = JSON.parse(text);
85
+ // Validate all keys exist
86
+ const keys = [
87
+ "tokensLine", "costLine", "modelLine", "busiestDayLine",
88
+ "sessionLine", "peakHoursLine", "archetypeLine", "summaryLine",
89
+ ];
90
+ const fallback = mode === "dark" ? darkFallbacks : sassyFallbacks;
91
+ for (const key of keys) {
92
+ if (!parsed[key] || typeof parsed[key] !== "string") {
93
+ parsed[key] = fallback[key];
94
+ }
95
+ }
96
+ return parsed;
97
+ }
98
+ catch (err) {
99
+ console.log(` AI commentary failed (${err.message}), using pre-written commentary.`);
100
+ return mode === "dark" ? darkFallbacks : sassyFallbacks;
101
+ }
102
+ }
@@ -0,0 +1,2 @@
1
+ import type { WrappedStats, DateRange } from "./types.js";
2
+ export declare function loadAndComputeStats(range?: DateRange, periodLabel?: string): Promise<WrappedStats>;