Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@
"@clack/prompts": "^0.11.0",
"@resvg/resvg-wasm": "^2.6.2",
"react": "^19.2.3",
"satori": "^0.18.3",
"xdg-basedir": "^5.1.0"
"satori": "^0.18.3"
},
"devDependencies": {
"@semantic-release/exec": "^7.1.0",
Expand Down
86 changes: 56 additions & 30 deletions src/collector.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Data collector - reads Claude Code storage and returns raw data

import { createReadStream } from "node:fs";
import { createReadStream, existsSync } from "node:fs";
import { readFile, readdir, realpath, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import os from "node:os";
import { createInterface } from "node:readline";
import { calculateCostUSD, getModelPricing, type ModelPricing } from "./pricing";
import { formatDateKey } from "./utils/dates";

export interface ClaudeStatsCache {
version?: number;
Expand Down Expand Up @@ -38,12 +39,31 @@ export interface ClaudeStatsCache {
}

const CLAUDE_DATA_PATH = join(os.homedir(), ".claude");
const CLAUDE_STATS_CACHE_PATH = join(CLAUDE_DATA_PATH, "stats-cache.json");
const CLAUDE_HISTORY_PATH = join(CLAUDE_DATA_PATH, "history.jsonl");
const CLAUDE_PROJECTS_DIR = "projects";
const CLAUDE_CONFIG_PATH = join(os.homedir(), ".config", "claude");
const CLAUDE_PROJECTS_DIR = "projects";
const CLAUDE_CONFIG_DIR_ENV = "CLAUDE_CONFIG_DIR";

// Resolve Claude data path
// Priority: 1. CLAUDE_CONFIG_DIR env var, 2. ~/.config/claude (XDG), 3. ~/.claude (legacy)
export function resolveClaudeDataPath(): string | null {
const envPath = process.env[CLAUDE_CONFIG_DIR_ENV]?.trim();
if (envPath && existsSync(join(envPath, "stats-cache.json"))) {
return envPath;
}

const candidates = [
CLAUDE_CONFIG_PATH, // XDG standard (~/.config/claude)
CLAUDE_DATA_PATH, // Legacy (~/.claude)
];

for (const path of candidates) {
if (existsSync(join(path, "stats-cache.json"))) {
return path;
}
}
return null;
}

export interface ClaudeUsageSummary {
totalInputTokens: number;
totalOutputTokens: number;
Expand All @@ -59,24 +79,32 @@ export interface ClaudeUsageSummary {
}

export async function checkClaudeDataExists(): Promise<boolean> {
try {
await readFile(CLAUDE_STATS_CACHE_PATH);
return true;
} catch {
return false;
}
return resolveClaudeDataPath() !== null;
}

function isValidStatsCache(data: unknown): data is ClaudeStatsCache {
return typeof data === "object" && data !== null;
}

export async function loadClaudeStatsCache(): Promise<ClaudeStatsCache> {
const raw = await readFile(CLAUDE_STATS_CACHE_PATH, "utf8");
return JSON.parse(raw) as ClaudeStatsCache;
const dataPath = resolveClaudeDataPath();
if (!dataPath) throw new Error("Claude data not found");
const raw = await readFile(join(dataPath, "stats-cache.json"), "utf8");
const parsed: unknown = JSON.parse(raw);
if (!isValidStatsCache(parsed)) {
throw new Error("Invalid stats-cache.json format");
}
return parsed;
}

export async function collectClaudeProjects(year: number): Promise<Set<string>> {
const projects = new Set<string>();
const dataPath = resolveClaudeDataPath();
if (!dataPath) return projects;

try {
const raw = await readFile(CLAUDE_HISTORY_PATH, "utf8");
const historyPath = join(dataPath, "history.jsonl");
const raw = await readFile(historyPath, "utf8");
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
Expand Down Expand Up @@ -127,9 +155,11 @@ export async function collectClaudeUsageSummary(year: number): Promise<ClaudeUsa
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
let entry: any;
let entry: Record<string, unknown>;
try {
entry = JSON.parse(trimmed);
const parsed: unknown = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null) continue;
entry = parsed as Record<string, unknown>;
} catch {
continue;
}
Expand All @@ -142,8 +172,8 @@ export async function collectClaudeUsageSummary(year: number): Promise<ClaudeUsa
processedHashes.add(uniqueHash);
}

const timestamp = entry?.timestamp;
if (!timestamp) continue;
const timestamp = entry.timestamp;
if (typeof timestamp !== "number" && typeof timestamp !== "string") continue;
const entryDate = new Date(timestamp);
if (Number.isNaN(entryDate.getTime()) || entryDate.getFullYear() !== year) {
continue;
Expand All @@ -157,15 +187,16 @@ export async function collectClaudeUsageSummary(year: number): Promise<ClaudeUsa
dailyActivity.set(dateKey, (dailyActivity.get(dateKey) || 0) + 1);
totalMessages += 1;

const sessionId = typeof entry?.sessionId === "string" ? entry.sessionId : undefined;
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
if (sessionId) {
sessionIds.add(sessionId);
}

const usage = entry?.message?.usage;
const model = typeof entry?.message?.model === "string" ? entry.message.model : undefined;
const message = entry.message as Record<string, unknown> | undefined;
const usage = message?.usage as Record<string, unknown> | undefined;
const model = typeof message?.model === "string" ? message.model : undefined;

const rawCost = entry?.costUSD;
const rawCost = entry.costUSD;
const hasCost = typeof rawCost === "number" && Number.isFinite(rawCost);
if (hasCost) {
totalCostUSD += rawCost;
Expand Down Expand Up @@ -227,9 +258,10 @@ export async function collectClaudeUsageSummary(year: number): Promise<ClaudeUsa
};
}

function createUniqueHash(entry: any): string | null {
const messageId = entry?.message?.id;
const requestId = entry?.requestId;
function createUniqueHash(entry: Record<string, unknown>): string | null {
const message = entry.message as Record<string, unknown> | undefined;
const messageId = message?.id;
const requestId = entry.requestId;
if (!messageId || !requestId) return null;
return `${messageId}:${requestId}`;
}
Expand Down Expand Up @@ -306,9 +338,3 @@ function ensureNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

function formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
95 changes: 72 additions & 23 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as p from "@clack/prompts";
import { join } from "node:path";
import { parseArgs } from "node:util";

import { checkClaudeDataExists } from "./collector";
import { checkClaudeDataExists, resolveClaudeDataPath } from "./collector";
import { calculateStats } from "./stats";
import { generateImage } from "./image/generator";
import { displayInTerminal, getTerminalName } from "./terminal/display";
Expand All @@ -25,13 +25,19 @@ USAGE:
cc-wrapped [OPTIONS]

OPTIONS:
--year <YYYY> Generate wrapped for a specific year (default: current year)
--help, -h Show this help message
--version, -v Show version number
-y, --year <YYYY> Generate wrapped for a specific year (default: current year)
-c, --config-dir <PATH> Path to Claude Code config directory (default: auto-detect)
-o, --output <PATH> Output path for image (default: ~/cc-wrapped-YYYY.png)
-V, --verbose Show debug information
-h, --help Show this help message
-v, --version Show version number

EXAMPLES:
cc-wrapped # Generate current year wrapped
cc-wrapped --year 2025 # Generate 2025 wrapped
cc-wrapped # Generate current year wrapped
cc-wrapped --year 2025 # Generate 2025 wrapped
cc-wrapped -c ~/.config/claude # Use specific config directory
cc-wrapped -o ~/Desktop/wrapped.png # Save to specific location
cc-wrapped --verbose # Show debug info
`);
}

Expand All @@ -41,6 +47,9 @@ async function main() {
args: process.argv.slice(2),
options: {
year: { type: "string", short: "y" },
"config-dir": { type: "string", short: "c" },
output: { type: "string", short: "o" },
verbose: { type: "boolean", short: "V" },
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
},
Expand All @@ -58,10 +67,29 @@ async function main() {
process.exit(0);
}

// Set config dir from CLI arg (takes priority over auto-detection)
if (values["config-dir"]) {
process.env.CLAUDE_CONFIG_DIR = values["config-dir"];
}

const verbose = values.verbose ?? false;

p.intro("claude code wrapped");

// Show verbose debug info
if (verbose) {
const configPath = resolveClaudeDataPath();
p.log.info(`Config directory: ${configPath ?? "not found"}`);
}

const requestedYear = values.year ? parseInt(values.year, 10) : new Date().getFullYear();

// Validate year parameter
if (Number.isNaN(requestedYear) || requestedYear < 2024 || requestedYear > new Date().getFullYear()) {
p.cancel(`Invalid year: ${values.year}. Must be between 2024 and ${new Date().getFullYear()}`);
process.exit(1);
}

const availability = isWrappedAvailable(requestedYear);
if (!availability.available) {
if (Array.isArray(availability.message)) {
Expand All @@ -75,7 +103,7 @@ async function main() {

const dataExists = await checkClaudeDataExists();
if (!dataExists) {
p.cancel("Claude Code data not found in ~/.claude\n\nMake sure you have used Claude Code at least once.");
p.cancel("Claude Code data not found in ~/.config/claude or ~/.claude\n\nMake sure you have used Claude Code at least once.");
process.exit(0);
}

Expand All @@ -87,7 +115,8 @@ async function main() {
stats = await calculateStats(requestedYear);
} catch (error) {
spinner.stop("Failed to collect stats");
p.cancel(`Error: ${error}`);
const message = error instanceof Error ? error.message : String(error);
p.cancel(`Failed to collect stats: ${message}`);
process.exit(1);
}

Expand All @@ -97,6 +126,11 @@ async function main() {
process.exit(0);
}

// Show verbose stats summary
if (verbose) {
p.log.info(`Sessions: ${stats.totalSessions}, Messages: ${stats.totalMessages}, Projects: ${stats.totalProjects}`);
}

spinner.stop("Found your stats!");

const activityDates = Array.from(stats.dailyActivity.keys())
Expand Down Expand Up @@ -157,24 +191,39 @@ async function main() {
p.log.info("You can save the image to disk instead.");
}

const defaultPath = join(process.env.HOME || "~", filename);
// Handle image saving
const outputPath = values.output;
if (outputPath) {
// If --output is provided, save directly without prompting
try {
await Bun.write(outputPath, image.fullSize);
p.log.success(`Saved to ${outputPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
p.log.error(`Failed to save: ${message}`);
}
} else {
// Interactive mode: prompt user
const defaultPath = join(process.env.HOME || "~", filename);

const shouldSave = await p.confirm({
message: `Save image to ~/${filename}?`,
initialValue: true,
});
const shouldSave = await p.confirm({
message: `Save image to ~/${filename}?`,
initialValue: true,
});

if (p.isCancel(shouldSave)) {
p.outro("Cancelled");
process.exit(0);
}
if (p.isCancel(shouldSave)) {
p.outro("Cancelled");
process.exit(0);
}

if (shouldSave) {
try {
await Bun.write(defaultPath, image.fullSize);
p.log.success(`Saved to ${defaultPath}`);
} catch (error) {
p.log.error(`Failed to save: ${error}`);
if (shouldSave) {
try {
await Bun.write(defaultPath, image.fullSize);
p.log.success(`Saved to ${defaultPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
p.log.error(`Failed to save: ${message}`);
}
}
}

Expand Down
8 changes: 1 addition & 7 deletions src/stats.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ClaudeCodeStats, ModelStats, ProviderStats, WeekdayActivity } from "./types";
import { collectClaudeProjects, collectClaudeUsageSummary, loadClaudeStatsCache } from "./collector";
import { fetchModelsData, getModelDisplayName, getModelProvider, getProviderDisplayName } from "./models";
import { formatDateKey } from "./utils/dates";

export async function calculateStats(year: number): Promise<ClaudeCodeStats> {
const [, statsCache, projects, usageSummary] = await Promise.all([
Expand Down Expand Up @@ -239,13 +240,6 @@ function findFirstActivityDate(dailyActivity: Map<string, number>): Date {
return new Date(dates[0]);
}

function formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

function calculateStreaks(
dailyActivity: Map<string, number>,
year: number
Expand Down