diff --git a/bun.lock b/bun.lock index c98d42c..5dee7bc 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "@resvg/resvg-wasm": "^2.6.2", "react": "^19.2.3", "satori": "^0.18.3", - "xdg-basedir": "^5.1.0", }, "devDependencies": { "@semantic-release/exec": "^7.1.0", @@ -758,8 +757,6 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], diff --git a/package.json b/package.json index e15526b..5409402 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/collector.ts b/src/collector.ts index 483143e..c3e2fec 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -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; @@ -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; @@ -59,24 +79,32 @@ export interface ClaudeUsageSummary { } export async function checkClaudeDataExists(): Promise { - 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 { - 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> { const projects = new Set(); + 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 { @@ -127,9 +155,11 @@ export async function collectClaudeUsageSummary(year: number): Promise; try { - entry = JSON.parse(trimmed); + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null) continue; + entry = parsed as Record; } catch { continue; } @@ -142,8 +172,8 @@ export async function collectClaudeUsageSummary(year: number): Promise | undefined; + const usage = message?.usage as Record | 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; @@ -227,9 +258,10 @@ export async function collectClaudeUsageSummary(year: number): Promise): string | null { + const message = entry.message as Record | undefined; + const messageId = message?.id; + const requestId = entry.requestId; if (!messageId || !requestId) return null; return `${messageId}:${requestId}`; } @@ -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}`; -} diff --git a/src/index.ts b/src/index.ts index a9313be..3ada573 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -25,13 +25,19 @@ USAGE: cc-wrapped [OPTIONS] OPTIONS: - --year Generate wrapped for a specific year (default: current year) - --help, -h Show this help message - --version, -v Show version number + -y, --year Generate wrapped for a specific year (default: current year) + -c, --config-dir Path to Claude Code config directory (default: auto-detect) + -o, --output 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 `); } @@ -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" }, }, @@ -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)) { @@ -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); } @@ -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); } @@ -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()) @@ -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}`); + } } } diff --git a/src/stats.ts b/src/stats.ts index 56fec40..45ca59d 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -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 { const [, statsCache, projects, usageSummary] = await Promise.all([ @@ -239,13 +240,6 @@ function findFirstActivityDate(dailyActivity: Map): 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, year: number