ccwrap 0.1.0 → 0.2.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/dist/cli.js CHANGED
@@ -88,13 +88,14 @@ const darkBanner = `
88
88
  `;
89
89
  program
90
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")
91
+ .description("ccwrap - Your AI coding stats, meme-ified into a shareable video")
92
+ .version("0.2.0")
93
+ .option("-o, --output <path>", "Output video file path", "ccwrap.mp4")
94
94
  .option("-m, --mode <mode>", "Commentary mode: sassy or dark", "sassy")
95
95
  .option("-p, --period <period>", "Time period: week, month, quarter, all (default: month)")
96
96
  .option("--since <date>", "Start date (YYYY-MM-DD), mutually exclusive with --period")
97
97
  .option("--until <date>", "End date (YYYY-MM-DD), requires --since")
98
+ .option("--skip-weekends", "Exclude weekend activity from stats")
98
99
  .option("--json", "Output stats as JSON instead of video")
99
100
  .option("--stats-only", "Print stats to console without rendering video")
100
101
  .action(async (options) => {
@@ -113,7 +114,7 @@ program
113
114
  const spinner = ora({ text: `Scanning Claude Code usage data...${periodHint}`, color: isDark ? "red" : "magenta" }).start();
114
115
  let stats;
115
116
  try {
116
- stats = await loadAndComputeStats(range, periodLabel);
117
+ stats = await loadAndComputeStats(range, periodLabel, options.skipWeekends);
117
118
  spinner.succeed(chalk.green("Usage data loaded!"));
118
119
  }
119
120
  catch (err) {
@@ -10,4 +10,5 @@ export interface Commentary {
10
10
  summaryLine: string;
11
11
  }
12
12
  export type Mode = "sassy" | "dark";
13
+ export declare function buildPrompt(stats: WrappedStats, mode: Mode): string;
13
14
  export declare function generateCommentary(stats: WrappedStats, mode: Mode): Promise<Commentary>;
@@ -1,5 +1,5 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
- function buildPrompt(stats, mode) {
2
+ export function buildPrompt(stats, mode) {
3
3
  const modeDescription = mode === "dark"
4
4
  ? `DARK MODE: Your commentary should be darkly humorous with environmental/existential undertones.
5
5
  Think: water usage, carbon footprint, e-waste, heat death of the universe, humanity's dependence on AI.
@@ -19,7 +19,7 @@ function buildPrompt(stats, mode) {
19
19
  const periodContext = stats.periodLabel.startsWith("Your ")
20
20
  ? `This recap covers ${stats.periodLabel.toLowerCase().replace("your ", "the user's last ")} of usage.`
21
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.
22
+ return `You are writing short, punchy one-liner commentary for a "ccwrap" recap video — a shareable stats recap of someone's AI coding assistant usage. Each line appears as a caption on an animated slide.
23
23
 
24
24
  ${periodContext}
25
25
 
@@ -73,6 +73,7 @@ export async function generateCommentary(stats, mode) {
73
73
  console.log(" No ANTHROPIC_API_KEY found, using pre-written commentary.");
74
74
  return mode === "dark" ? darkFallbacks : sassyFallbacks;
75
75
  }
76
+ /* v8 ignore start -- requires live API key */
76
77
  try {
77
78
  const client = new Anthropic({ apiKey });
78
79
  const message = await client.messages.create({
@@ -99,4 +100,5 @@ export async function generateCommentary(stats, mode) {
99
100
  console.log(` AI commentary failed (${err.message}), using pre-written commentary.`);
100
101
  return mode === "dark" ? darkFallbacks : sassyFallbacks;
101
102
  }
103
+ /* v8 ignore stop */
102
104
  }
@@ -1,2 +1,23 @@
1
- import type { WrappedStats, DateRange } from "./types.js";
2
- export declare function loadAndComputeStats(range?: DateRange, periodLabel?: string): Promise<WrappedStats>;
1
+ import type { RawJSONLEntry, WrappedStats, DateRange } from "./types.js";
2
+ export declare function estimateCost(model: string, inputTokens: number, outputTokens: number, cacheCreationTokens: number, cacheReadTokens: number): number;
3
+ export declare function extractProject(filePath: string): string;
4
+ export declare function loadAndComputeStats(range?: DateRange, periodLabel?: string, skipWeekends?: boolean): Promise<WrappedStats>;
5
+ export declare function computeStatsFromEntries(allEntries: (RawJSONLEntry & {
6
+ _file: string;
7
+ })[], opts?: {
8
+ range?: DateRange;
9
+ periodLabel?: string;
10
+ skipWeekends?: boolean;
11
+ }): WrappedStats;
12
+ export declare function computeArchetype(params: {
13
+ nightOwlScore: number;
14
+ weekendWarriorScore: number;
15
+ avgSessionMinutes: number;
16
+ totalSessions: number;
17
+ streakDays: number;
18
+ totalTokens: number;
19
+ favoriteModel: string;
20
+ }): {
21
+ archetype: string;
22
+ archetypeDescription: string;
23
+ };
@@ -13,7 +13,7 @@ const PRICING = {
13
13
  "claude-3-5-sonnet-20241022": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
14
14
  "claude-3-5-haiku-20241022": { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 },
15
15
  };
16
- function estimateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
16
+ export function estimateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
17
17
  const pricing = Object.entries(PRICING).find(([key]) => model.includes(key))?.[1]
18
18
  ?? (model.includes("opus") ? PRICING["claude-opus-4-6"]
19
19
  : model.includes("haiku") ? PRICING["claude-haiku-4-5-20251001"]
@@ -59,7 +59,7 @@ async function parseJSONLFile(filePath) {
59
59
  }
60
60
  return entries;
61
61
  }
62
- function extractProject(filePath) {
62
+ export function extractProject(filePath) {
63
63
  // Path format: ~/.claude/projects/{encoded-project-path}/{sessionId}.jsonl
64
64
  const parts = filePath.split("/projects/");
65
65
  if (parts.length < 2)
@@ -72,7 +72,7 @@ function extractProject(filePath) {
72
72
  .replace(/^-/, "/")
73
73
  .replace(/-/g, "/");
74
74
  }
75
- export async function loadAndComputeStats(range, periodLabel) {
75
+ export async function loadAndComputeStats(range, periodLabel, skipWeekends) {
76
76
  const files = await findJSONLFiles();
77
77
  if (files.length === 0) {
78
78
  throw new Error("No Claude Code usage data found. Make sure you have used Claude Code before.");
@@ -88,6 +88,10 @@ export async function loadAndComputeStats(range, periodLabel) {
88
88
  if (allEntries.length === 0) {
89
89
  throw new Error("No usage data entries found in Claude Code logs.");
90
90
  }
91
+ return computeStatsFromEntries(allEntries, { range, periodLabel, skipWeekends });
92
+ }
93
+ export function computeStatsFromEntries(allEntries, opts = {}) {
94
+ const { range, periodLabel, skipWeekends } = opts;
91
95
  // Sort by timestamp
92
96
  allEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
93
97
  // Filter by date range if provided
@@ -105,6 +109,19 @@ export async function loadAndComputeStats(range, periodLabel) {
105
109
  throw new Error(`No usage data found in the selected time period (${before} entries exist outside this range).`);
106
110
  }
107
111
  }
112
+ // Filter out weekends if requested
113
+ if (skipWeekends) {
114
+ const before = allEntries.length;
115
+ const filtered = allEntries.filter(e => {
116
+ const day = new Date(e.timestamp).getDay();
117
+ return day !== 0 && day !== 6;
118
+ });
119
+ allEntries.length = 0;
120
+ allEntries.push(...filtered);
121
+ if (allEntries.length === 0) {
122
+ throw new Error(`No weekday usage data found (${before} weekend entries were excluded).`);
123
+ }
124
+ }
108
125
  // Build session map
109
126
  const sessionMap = new Map();
110
127
  for (const entry of allEntries) {
@@ -307,7 +324,7 @@ export async function loadAndComputeStats(range, periodLabel) {
307
324
  kwhUsed: Math.round(totalTokens / 1_000 * 0.001 * 100) / 100,
308
325
  };
309
326
  }
310
- function computeArchetype(params) {
327
+ export function computeArchetype(params) {
311
328
  const { nightOwlScore, weekendWarriorScore, avgSessionMinutes, totalSessions, streakDays, totalTokens, favoriteModel } = params;
312
329
  if (nightOwlScore > 50) {
313
330
  return {
@@ -13,7 +13,7 @@ export const IntroSlide = ({ mode, periodLabel }) => {
13
13
  const taglineScale = spring({ frame: Math.max(0, frame - 35), fps, from: 0.5, to: 1, config: { damping: 8 } });
14
14
  const taglineOpacity = interpolate(frame, [35, 50], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
15
15
  const rotation = interpolate(frame, [0, 150], [0, 360], { extrapolateRight: "extend" });
16
- return (_jsxs("div", { style: { ...baseSlide, ...gradientBg(135, c), color: c.text }, children: [_jsx(ParticleField, { count: 40 }), _jsx(GlowOrb, { color: c.primary, size: 250, x: 15, y: 25 }), _jsx(GlowOrb, { color: c.secondary, size: 200, x: 80, y: 70 }), _jsx(GlowOrb, { color: c.accent, size: 150, x: 50, y: 15, speed: 0.3 }), _jsx("div", { style: { position: "absolute", width: 180, height: 180, borderRadius: "50%", border: `2px solid ${c.primary}30`, transform: `scale(${logoScale}) rotate(${rotation}deg)` } }), _jsx("div", { style: { position: "absolute", width: 150, height: 150, borderRadius: "50%", border: `1px solid ${c.secondary}20`, transform: `scale(${logoScale}) rotate(-${rotation * 0.7}deg)` } }), _jsx("div", { style: { transform: `scale(${logoScale})`, fontSize: 72, fontWeight: 900, background: `linear-gradient(135deg, ${c.primary}, ${c.secondary})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", zIndex: 1 }, children: "</>" }), _jsxs("div", { style: { opacity: titleOpacity, fontSize: 48, fontWeight: 800, marginTop: 24, letterSpacing: "-0.02em", zIndex: 1 }, children: [_jsx("span", { style: { color: c.text }, children: "Claude Code " }), _jsx("span", { style: { background: `linear-gradient(90deg, ${c.primary}, ${c.accent})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }, children: "Wrapped" })] }), _jsx(FadeIn, { delay: 35, children: _jsx("div", { style: { opacity: taglineOpacity, transform: `scale(${taglineScale})`, fontSize: 22, fontWeight: 500, color: c.textMuted, marginTop: 20, zIndex: 1 }, children: periodLabel.startsWith("Your ")
16
+ return (_jsxs("div", { style: { ...baseSlide, ...gradientBg(135, c), color: c.text }, children: [_jsx(ParticleField, { count: 40 }), _jsx(GlowOrb, { color: c.primary, size: 250, x: 15, y: 25 }), _jsx(GlowOrb, { color: c.secondary, size: 200, x: 80, y: 70 }), _jsx(GlowOrb, { color: c.accent, size: 150, x: 50, y: 15, speed: 0.3 }), _jsx("div", { style: { position: "absolute", width: 180, height: 180, borderRadius: "50%", border: `2px solid ${c.primary}30`, transform: `scale(${logoScale}) rotate(${rotation}deg)` } }), _jsx("div", { style: { position: "absolute", width: 150, height: 150, borderRadius: "50%", border: `1px solid ${c.secondary}20`, transform: `scale(${logoScale}) rotate(-${rotation * 0.7}deg)` } }), _jsx("div", { style: { transform: `scale(${logoScale})`, fontSize: 72, fontWeight: 900, background: `linear-gradient(135deg, ${c.primary}, ${c.secondary})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", zIndex: 1 }, children: "</>" }), _jsx("div", { style: { opacity: titleOpacity, fontSize: 48, fontWeight: 800, marginTop: 24, letterSpacing: "-0.02em", zIndex: 1 }, children: _jsx("span", { style: { background: `linear-gradient(90deg, ${c.primary}, ${c.accent})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }, children: "ccwrap" }) }), _jsx(FadeIn, { delay: 35, children: _jsx("div", { style: { opacity: taglineOpacity, transform: `scale(${taglineScale})`, fontSize: 22, fontWeight: 500, color: c.textMuted, marginTop: 20, zIndex: 1 }, children: periodLabel.startsWith("Your ")
17
17
  ? mode === "dark"
18
18
  ? `${periodLabel} in AI. The planet remembers.`
19
19
  : `${periodLabel} in AI-assisted coding`
@@ -47,6 +47,6 @@ export const SummarySlide = ({ stats, commentary, mode }) => {
47
47
  border: `1px solid ${c.primary}50`, borderRadius: 24, padding: "36px 44px",
48
48
  maxWidth: 620, width: "92%",
49
49
  boxShadow: `0 0 36px ${c.primary}20, 0 12px 36px rgba(0,0,0,0.5)`, zIndex: 1,
50
- }, children: [_jsx("div", { style: { textAlign: "center", marginBottom: 20 }, children: _jsx("div", { style: { fontSize: 28, fontWeight: 900, ...glowText(c.primary), background: `linear-gradient(90deg, ${c.primary}, ${c.secondary})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }, children: getSummaryTitle(stats.periodLabel, mode) }) }), _jsx(FadeIn, { delay: 15, children: _jsxs("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "14px 36px" }, children: [_jsx(Row, { label: "Tokens", value: formatNumber(stats.totalTokens), color: c.gold, textColor: c.textMuted }), _jsx(Row, { label: "Cost", value: `$${stats.totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, color: mode === "dark" ? c.accent : "#10b981", textColor: c.textMuted }), _jsx(Row, { label: "Sessions", value: stats.totalSessions.toLocaleString(), color: c.primary, textColor: c.textMuted }), _jsx(Row, { label: "Messages", value: formatNumber(stats.totalMessages), color: c.pink, textColor: c.textMuted }), _jsx(Row, { label: "Active Days", value: stats.totalDaysActive.toLocaleString(), color: c.secondary, textColor: c.textMuted }), _jsx(Row, { label: "Fav Model", value: getModelShortName(stats.favoriteModel), color: c.accent, textColor: c.textMuted }), mode === "dark" && _jsxs(_Fragment, { children: [_jsx(Row, { label: "Water", value: `${stats.waterLiters.toLocaleString(undefined, { maximumFractionDigits: 1 })}L`, color: c.secondary, textColor: c.textMuted }), _jsx(Row, { label: "CO\u2082", value: `${(stats.co2Grams / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 })}kg`, color: c.textMuted, textColor: c.textMuted })] })] }) }), _jsx(FadeIn, { delay: 30, children: _jsxs("div", { style: { marginTop: 20, textAlign: "center", padding: "12px 20px", background: `linear-gradient(135deg, ${c.primary}20, ${c.accent}20)`, borderRadius: 12, border: `1px solid ${c.primary}40` }, children: [_jsx("div", { style: { fontSize: 12, color: c.textMuted, textTransform: "uppercase", letterSpacing: "0.15em" }, children: "Your Archetype" }), _jsx("div", { style: { fontSize: 26, fontWeight: 800, background: `linear-gradient(90deg, ${c.primary}, ${c.accent})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", marginTop: 3 }, children: stats.archetype })] }) }), _jsx(FadeIn, { delay: 40, children: _jsx("div", { style: { textAlign: "center", marginTop: 16, fontSize: 15, fontStyle: "italic", color: c.textMuted }, children: commentary }) }), _jsx(FadeIn, { delay: 50, children: _jsxs("div", { style: { textAlign: "center", marginTop: 14, fontSize: 12, color: c.textDim }, children: ["ccwrapped", mode === "dark" ? " — know your impact" : " — put meme software on the map"] }) })] })] }));
50
+ }, children: [_jsx("div", { style: { textAlign: "center", marginBottom: 20 }, children: _jsx("div", { style: { fontSize: 28, fontWeight: 900, ...glowText(c.primary), background: `linear-gradient(90deg, ${c.primary}, ${c.secondary})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }, children: getSummaryTitle(stats.periodLabel, mode) }) }), _jsx(FadeIn, { delay: 15, children: _jsxs("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "14px 36px" }, children: [_jsx(Row, { label: "Tokens", value: formatNumber(stats.totalTokens), color: c.gold, textColor: c.textMuted }), _jsx(Row, { label: "Cost", value: `$${stats.totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, color: mode === "dark" ? c.accent : "#10b981", textColor: c.textMuted }), _jsx(Row, { label: "Sessions", value: stats.totalSessions.toLocaleString(), color: c.primary, textColor: c.textMuted }), _jsx(Row, { label: "Messages", value: formatNumber(stats.totalMessages), color: c.pink, textColor: c.textMuted }), _jsx(Row, { label: "Active Days", value: stats.totalDaysActive.toLocaleString(), color: c.secondary, textColor: c.textMuted }), _jsx(Row, { label: "Fav Model", value: getModelShortName(stats.favoriteModel), color: c.accent, textColor: c.textMuted }), mode === "dark" && _jsxs(_Fragment, { children: [_jsx(Row, { label: "Water", value: `${stats.waterLiters.toLocaleString(undefined, { maximumFractionDigits: 1 })}L`, color: c.secondary, textColor: c.textMuted }), _jsx(Row, { label: "CO\u2082", value: `${(stats.co2Grams / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 })}kg`, color: c.textMuted, textColor: c.textMuted })] })] }) }), _jsx(FadeIn, { delay: 30, children: _jsxs("div", { style: { marginTop: 20, textAlign: "center", padding: "12px 20px", background: `linear-gradient(135deg, ${c.primary}20, ${c.accent}20)`, borderRadius: 12, border: `1px solid ${c.primary}40` }, children: [_jsx("div", { style: { fontSize: 12, color: c.textMuted, textTransform: "uppercase", letterSpacing: "0.15em" }, children: "Your Archetype" }), _jsx("div", { style: { fontSize: 26, fontWeight: 800, background: `linear-gradient(90deg, ${c.primary}, ${c.accent})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", marginTop: 3 }, children: stats.archetype })] }) }), _jsx(FadeIn, { delay: 40, children: _jsx("div", { style: { textAlign: "center", marginTop: 16, fontSize: 15, fontStyle: "italic", color: c.textMuted }, children: commentary }) }), _jsx(FadeIn, { delay: 50, children: _jsxs("div", { style: { textAlign: "center", marginTop: 14, fontSize: 12, color: c.textDim }, children: ["ccwrap", mode === "dark" ? " — know your impact" : " — put meme software on the map"] }) })] })] }));
51
51
  };
52
52
  const Row = ({ label, value, color, textColor }) => (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [_jsx("div", { style: { fontSize: 14, color: textColor }, children: label }), _jsx("div", { style: { fontSize: 20, fontWeight: 700, color }, children: value })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccwrap",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code Wrapped - Your AI coding stats, meme-ified into a shareable video",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,8 @@
23
23
  "start": "tsx src/cli.ts",
24
24
  "preview": "remotion preview src/video/index.ts",
25
25
  "render": "tsx src/cli.ts",
26
+ "test": "vitest run",
27
+ "test:coverage": "vitest run --coverage",
26
28
  "prepublishOnly": "npm run build"
27
29
  },
28
30
  "bin": {
@@ -52,7 +54,9 @@
52
54
  "devDependencies": {
53
55
  "@types/node": "^22.0.0",
54
56
  "@types/react": "^18.3.0",
57
+ "@vitest/coverage-v8": "^4.1.0",
55
58
  "tsx": "^4.0.0",
56
- "typescript": "^5.6.0"
59
+ "typescript": "^5.6.0",
60
+ "vitest": "^4.1.0"
57
61
  }
58
62
  }