import { $ } from "bun"; type NumberLike = number | undefined | null; interface ModelBreakdown { modelName: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; } interface DailyEntry { date: string; // YYYY-MM-DD inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; totalCost: number; modelsUsed?: string[]; modelBreakdowns?: ModelBreakdown[]; } interface Totals { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; totalCost: number; } interface CcFile { daily: DailyEntry[]; totals?: Totals; } interface ExtendedCcFile { totals?: Totals; claudeCode?: { daily: DailyEntry[]; totals: Totals; }; codex?: { daily: DailyEntry[]; totals: Totals; }; } interface CodexModelData { inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; isFallback: boolean; } interface CodexDailyEntry { date: string; inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; costUSD: number; models: Record; } interface CodexFile { daily: CodexDailyEntry[]; totals: { inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; costUSD: number; }; } type ProviderType = "claude" | "codex"; const VERSION = "1.0.0"; const numberFormatter = new Intl.NumberFormat("en-US"); const YES_PATTERN = /^(y|yes)$/i; // ANSI color codes for better CLI output const colors = { reset: "\x1b[0m", bright: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m", gray: "\x1b[90m" }; const log = { error: (msg: string) => console.error(`${colors.red}[ccombine]${colors.reset} ${msg}`), warn: (msg: string) => console.warn(`${colors.yellow}[ccombine]${colors.reset} ${msg}`), success: (msg: string) => console.log(`${colors.green}[ccombine]${colors.reset} ${msg}`), info: (msg: string) => console.log(`${colors.cyan}[ccombine]${colors.reset} ${msg}`), dim: (msg: string) => console.log(`${colors.dim}${msg}${colors.reset}`) }; interface MergeConflict { date: string; existing: DailyEntry; incoming: DailyEntry; } interface MergeStats { added: string[]; replaced: string[]; unchanged: string[]; conflicts: MergeConflict[]; } interface TotalsDecision { totals: Totals; source: "computed" | "stored"; changed: boolean; } interface CommandInfo { name: string; description: string; usage: string; options: Array<{ flag: string; description: string; required?: boolean }>; examples: string[]; } const commands: Record = { auto: { name: "auto", description: "Automatically fetch and merge usage data from ccusage and cousage CLIs", usage: "ccombine auto [options]", options: [ { flag: "--base ", description: "Base cc.json file to merge with (default: public/data/cc.json)" }, { flag: "--out ", description: "Output file path (default: same as base)" }, { flag: "--ccusage-cmd ", description: "Custom ccusage command (default: bunx ccusage@latest --json)" }, { flag: "--cousage-cmd ", description: "Custom cousage command (default: bunx @ccusage/codex@latest --json)" }, { flag: "--accept-lower", description: "Automatically replace entries even if token count decreases" }, { flag: "--dry", description: "Dry run - preview changes without writing files" }, { flag: "--help", description: "Show this help message" } ], examples: [ "bun tools/ccombine.ts auto", "bun tools/ccombine.ts auto --out data/usage.json", "bun tools/ccombine.ts auto --dry --accept-lower", "bun tools/ccombine.ts auto --ccusage-cmd 'ccusage --json --days 30'" ] }, sync: { name: "sync", description: "Merge usage data from a JSON file into cc.json", usage: "ccombine sync [options]", options: [ { flag: "", description: "JSON file containing usage data to merge", required: true }, { flag: "", description: "Provider type: 'claude' or 'codex'", required: true }, { flag: "--base ", description: "Base cc.json file to merge with (default: public/data/cc.json)" }, { flag: "--out ", description: "Output file path (default: same as base)" }, { flag: "--accept-lower", description: "Automatically replace entries even if token count decreases" }, { flag: "--dry", description: "Dry run - preview changes without writing files" }, { flag: "--help", description: "Show this help message" } ], examples: [ "bun tools/ccombine.ts sync usage.json claude", "bun tools/ccombine.ts sync ./data/codex.json codex --out merged.json", "bun tools/ccombine.ts sync backup.json claude --dry" ] }, init: { name: "init", description: "Create an empty cc.json file", usage: "ccombine init [target] [options]", options: [ { flag: "[target]", description: "Target directory or file path (default: public/data/cc.json)" }, { flag: "--out ", description: "Explicit output file path (overrides target)" }, { flag: "--force", description: "Overwrite existing file" }, { flag: "--help", description: "Show this help message" } ], examples: [ "bun tools/ccombine.ts init", "bun tools/ccombine.ts init ./data", "bun tools/ccombine.ts init --out custom/path/usage.json", "bun tools/ccombine.ts init --force" ] } }; /** * Safely converts a value to a number, defaulting to 0 for invalid values * @param n - The value to convert (number, undefined, or null) * @returns A valid number or 0 if the input is invalid */ function toNumber(n: NumberLike): number { return typeof n === "number" && Number.isFinite(n) ? n : 0; } /** * Computes aggregate totals from an array of daily usage entries * @param daily - Array of daily usage entries to sum * @returns Totals object containing summed token counts and costs */ function computeTotals(daily: DailyEntry[]): Totals { const totals: Totals = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0, }; for (const d of daily) { totals.inputTokens += toNumber(d.inputTokens); totals.outputTokens += toNumber(d.outputTokens); totals.cacheCreationTokens += toNumber(d.cacheCreationTokens); totals.cacheReadTokens += toNumber(d.cacheReadTokens); totals.totalTokens += toNumber(d.totalTokens); totals.totalCost += toNumber(d.totalCost); } return totals; } /** * Determines if entry b should replace entry a based on data completeness * @param a - Existing entry in the dataset * @param b - New entry to potentially replace the existing one * @returns true if b has more tokens, higher cost, or more model breakdowns */ function isReplacementBetter(a: DailyEntry, b: DailyEntry): boolean { const aTokens = toNumber(a.totalTokens); const bTokens = toNumber(b.totalTokens); if (bTokens !== aTokens) return bTokens > aTokens; const aCost = toNumber(a.totalCost); const bCost = toNumber(b.totalCost); if (bCost !== aCost) return bCost > aCost; const aBreakdowns = a.modelBreakdowns?.length ?? 0; const bBreakdowns = b.modelBreakdowns?.length ?? 0; if (bBreakdowns !== aBreakdowns) return bBreakdowns > aBreakdowns; return false; } /** * Reads and parses a JSON file using Bun's file API * @param filePath - Absolute or relative path to the JSON file * @returns Parsed JSON content with the specified type * @throws Error if file cannot be read or parsed */ async function readJson(filePath: string): Promise { const file = Bun.file(filePath); return await file.json() as T; } /** * Checks if a file exists at the given path * @param filePath - Path to check for file existence * @returns true if file exists, false otherwise */ async function fileExists(filePath: string): Promise { return await Bun.file(filePath).exists(); } /** * Type guard to check if a value is a non-null object * @param value - Value to type check * @returns true if value is an object (not null), false otherwise */ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isCodexFile(value: unknown): value is CodexFile { if (!isObject(value)) return false; if (!Array.isArray(value["daily"])) return false; if (!isObject(value["totals"])) return false; return (value["daily"] as unknown[]).every((entry) => { if (!isObject(entry)) return false; const models = entry["models"]; return typeof entry["date"] === "string" && isObject(models); }); } function extractProviderEntries(section: unknown): DailyEntry[] { if (!isObject(section)) return []; const rawDaily = Array.isArray(section["daily"]) ? (section["daily"] as unknown[]) : []; return rawDaily.map(coerceDailyEntry); } function normalizeSlashes(input: string): string { return input.replaceAll("\\", "/"); } function trimTrailingSlash(pathname: string): string { return pathname.endsWith("/") && pathname !== "/" ? pathname.slice(0, -1) : pathname; } function stripDrivePrefix(pathname: string): string { return pathname.startsWith("/") && /^[A-Za-z]:/.test(pathname.slice(1)) ? pathname.slice(1) : pathname; } function resolvePath(...segments: Array): string { const cwdUrl = Bun.pathToFileURL(`${normalizeSlashes(process.cwd())}/`); let base = cwdUrl; segments.forEach((segment, index) => { if (segment == null) return; let normalized = normalizeSlashes(String(segment)); if (/^[A-Za-z]:\//.test(normalized)) { normalized = `/${normalized}`; } if (!normalized) return; const next = new URL(normalized, base); const isLast = index === segments.length - 1; if (isLast || normalized.endsWith("/")) { base = next; } else { base = new URL(next.href.endsWith("/") ? next.href : `${next.href}/`); } }); return stripDrivePrefix(trimTrailingSlash(Bun.fileURLToPath(base))); } function dirnamePath(filePath: string): string { let normalized = normalizeSlashes(filePath); if (/^[A-Za-z]:\//.test(normalized)) { normalized = `/${normalized}`; } const dirUrl = new URL("./", Bun.pathToFileURL(normalized)); return stripDrivePrefix(trimTrailingSlash(Bun.fileURLToPath(dirUrl))); } async function ensureDirectoryFor(filePath: string): Promise { const dir = dirnamePath(filePath); if (!dir) return; await $`mkdir -p ${dir}`; } function printUsage(): void { console.log(`${colors.bright}ccombine v${VERSION}${colors.reset}`); console.log(`${colors.dim}A utility for combining ccusage data${colors.reset}\n`); console.log(`${colors.bright}Usage:${colors.reset}`); console.log(" ccombine [options]\n"); console.log(`${colors.bright}Commands:${colors.reset}`); Object.values(commands).forEach(cmd => { console.log(` ${colors.cyan}${cmd.name.padEnd(8)}${colors.reset} ${cmd.description}`); }); console.log(`\n${colors.bright}Examples:${colors.reset}`); console.log(" ccombine auto # Fetch and merge latest usage data"); console.log(" ccombine sync data.json claude # Merge Claude data from file"); console.log(" ccombine init ./data # Initialize empty cc.json\n"); console.log(`${colors.dim}For command-specific help, use: ccombine --help${colors.reset}`); } function printCommandHelp(cmd: CommandInfo): void { console.log(`${colors.bright}${cmd.name}${colors.reset} - ${cmd.description}\n`); console.log(`${colors.bright}Usage:${colors.reset}`); console.log(` ${cmd.usage}\n`); console.log(`${colors.bright}Options:${colors.reset}`); cmd.options.forEach(opt => { const required = opt.required ? ` ${colors.red}(required)${colors.reset}` : ""; console.log(` ${colors.cyan}${opt.flag.padEnd(20)}${colors.reset} ${opt.description}${required}`); }); if (cmd.examples.length > 0) { console.log(`\n${colors.bright}Examples:${colors.reset}`); cmd.examples.forEach(ex => { console.log(` ${colors.dim}$${colors.reset} ${ex}`); }); } } async function handleInit(args: string[], defaultBasePath: string): Promise { let targetArg = ""; let outPath: string | undefined; let force = false; if (args.includes("--help") || args.includes("-h")) { printCommandHelp(commands.init); process.exit(0); } for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--out" || arg === "--base") { if (i + 1 >= args.length) { log.error(`Missing value for ${arg}`); console.log(`\nUse 'ccombine init --help' for usage information`); process.exit(1); } outPath = resolvePath(args[++i]); } else if (arg === "--force") { force = true; } else if (!arg.startsWith("-")) { if (targetArg) { log.error(`Unexpected positional argument: ${arg}`); console.log(`\nUse 'ccombine init --help' for usage information`); process.exit(1); } targetArg = arg; } else if (arg !== "--help" && arg !== "-h") { log.error(`Unknown option for init: ${arg}`); console.log(`\nUse 'ccombine init --help' for usage information`); process.exit(1); } } let targetPath = outPath ?? (targetArg ? resolvePath(targetArg) : defaultBasePath); if (!targetPath.endsWith(".json")) { targetPath = resolvePath(targetPath, "cc.json"); } if (await fileExists(targetPath)) { if (!force) { log.error(`File already exists: ${targetPath}`); console.log(`Use --force to overwrite, or choose a different path`); process.exit(1); } log.warn(`Overwriting existing file: ${targetPath}`); } await ensureDirectoryFor(targetPath); const blank: ExtendedCcFile = { totals: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0, }, claudeCode: { daily: [], totals: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0, }, }, codex: { daily: [], totals: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0, }, }, }; await Bun.write(targetPath, JSON.stringify(blank, null, 2) + "\n"); log.success(`Initialized blank cc.json at ${targetPath}`); } /** * Safely coerces an unknown value to a Totals object with defaults * @param t - Unknown value to coerce (typically from parsed JSON) * @returns Valid Totals object with numeric fields defaulting to 0 */ function coerceTotals(t: unknown): Totals { const r = isObject(t) ? t : {}; return { inputTokens: toNumber(r["inputTokens"] as NumberLike), outputTokens: toNumber(r["outputTokens"] as NumberLike), cacheCreationTokens: toNumber(r["cacheCreationTokens"] as NumberLike), cacheReadTokens: toNumber(r["cacheReadTokens"] as NumberLike), totalTokens: toNumber(r["totalTokens"] as NumberLike), totalCost: toNumber(r["totalCost"] as NumberLike), }; } /** * Safely coerces an unknown value to a DailyEntry object * @param item - Unknown value to coerce (typically from parsed JSON) * @returns Valid DailyEntry with all required fields populated */ function coerceDailyEntry(item: unknown): DailyEntry { const r = isObject(item) ? item : {}; const modelBreakdownsRaw = Array.isArray(r["modelBreakdowns"]) ? (r["modelBreakdowns"] as unknown[]) : []; const modelBreakdowns: ModelBreakdown[] = modelBreakdownsRaw.map((mb) => { const m = isObject(mb) ? mb : {}; return { modelName: typeof m["modelName"] === "string" ? (m["modelName"] as string) : "", inputTokens: toNumber(m["inputTokens"] as NumberLike), outputTokens: toNumber(m["outputTokens"] as NumberLike), cacheCreationTokens: toNumber(m["cacheCreationTokens"] as NumberLike), cacheReadTokens: toNumber(m["cacheReadTokens"] as NumberLike), cost: toNumber(m["cost"] as NumberLike), }; }); const modelsUsed = Array.isArray(r["modelsUsed"]) ? (r["modelsUsed"] as unknown[]).filter((x): x is string => typeof x === "string") : undefined; return { date: String((r["date"] as unknown) ?? ""), inputTokens: toNumber(r["inputTokens"] as NumberLike), outputTokens: toNumber(r["outputTokens"] as NumberLike), cacheCreationTokens: toNumber(r["cacheCreationTokens"] as NumberLike), cacheReadTokens: toNumber(r["cacheReadTokens"] as NumberLike), totalTokens: toNumber(r["totalTokens"] as NumberLike), totalCost: toNumber(r["totalCost"] as NumberLike), modelsUsed, modelBreakdowns: modelBreakdowns.length ? modelBreakdowns : undefined, }; } /** * Normalizes an unknown object to the expected CcFile shape * @param obj - Unknown object from JSON parse to normalize * @returns CcFile with validated daily entries and optional totals */ function normalizeCcShape(obj: unknown): CcFile { const o = isObject(obj) ? obj : {}; const rawDaily = Array.isArray(o["daily"]) ? (o["daily"] as unknown[]) : []; const daily = rawDaily.map(coerceDailyEntry); const totals = isObject(o["totals"]) ? coerceTotals(o["totals"]) : undefined; return { daily, totals }; } /** * Sorts daily entries by date in ascending chronological order * @param entries - Array of daily entries to sort * @returns New sorted array (does not mutate input) */ function sortByDateAsc(entries: DailyEntry[]): DailyEntry[] { return entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); } /** * Converts Codex date format to ISO date format * @param dateStr - Date string in "Sep 12, 2025" format * @returns ISO formatted date "2025-09-12" or original if parsing fails * @example normalizeCodexDate("Sep 12, 2025") // "2025-09-12" */ function normalizeCodexDate(dateStr: string): string { const months: Record = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" }; const match = dateStr.match(/^(\w{3})\s+(\d{1,2}),\s+(\d{4})$/); if (!match) return dateStr; const [, monthName, day, year] = match; const month = months[monthName]; if (!month) return dateStr; return `${year}-${month}-${day.padStart(2, "0")}`; } /** * Converts a Codex (cousage) daily entry to Claude Code (ccusage) format * @param codex - Codex format daily entry with models and USD costs * @returns DailyEntry with normalized date, combined output tokens, and model breakdowns */ function convertCodexToCc(codex: CodexDailyEntry): DailyEntry { const modelBreakdowns: ModelBreakdown[] = Object.entries(codex.models || {}).map(([modelName, data]) => ({ modelName, inputTokens: data.inputTokens, outputTokens: data.outputTokens + data.reasoningOutputTokens, cacheCreationTokens: 0, cacheReadTokens: data.cachedInputTokens, cost: 0 })); if (modelBreakdowns.length > 0) { const totalTokens = codex.totalTokens || 1; modelBreakdowns.forEach(mb => { const modelTokens = mb.inputTokens + mb.outputTokens + mb.cacheReadTokens; mb.cost = (modelTokens / totalTokens) * codex.costUSD; }); } return { date: normalizeCodexDate(codex.date), inputTokens: codex.inputTokens, outputTokens: codex.outputTokens + codex.reasoningOutputTokens, cacheCreationTokens: 0, cacheReadTokens: codex.cachedInputTokens, totalTokens: codex.totalTokens, totalCost: codex.costUSD, modelsUsed: Object.keys(codex.models || {}), modelBreakdowns: modelBreakdowns.length > 0 ? modelBreakdowns : undefined }; } /** * Fetches usage data from the cousage (Codex) CLI command * @param cmd - Shell command to execute (e.g., "cousage --json") * @returns Parsed CodexFile or null if command fails */ async function fetchCousageData(cmd: string): Promise { try { log.info(`Fetching Codex data...`); const result = await $`sh -c ${cmd}`.text(); return JSON.parse(result) as CodexFile; } catch (error) { log.error(`Error fetching Codex data: ${error}`); return null; } } /** * Fetches usage data from the ccusage (Claude Code) CLI command * @param cmd - Shell command to execute (e.g., "ccusage --json") * @returns Normalized CcFile or null if command fails */ async function fetchCcusageData(cmd: string): Promise { try { log.info(`Fetching Claude Code data...`); const result = await $`sh -c ${cmd}`.text(); return normalizeCcShape(JSON.parse(result)); } catch (error) { log.error(`Error fetching Claude Code data: ${error}`); return null; } } function formatNumber(n: number): string { return numberFormatter.format(Math.round(n)); } function formatTotals(totals: Totals): string { return `input:${formatNumber(totals.inputTokens)} | output:${formatNumber(totals.outputTokens)} | total:${formatNumber(totals.totalTokens)} | cost:${totals.totalCost.toFixed(4)}`; } function totalsEqual(a: Totals, b: Totals): boolean { return a.inputTokens === b.inputTokens && a.outputTokens === b.outputTokens && a.cacheCreationTokens === b.cacheCreationTokens && a.cacheReadTokens === b.cacheReadTokens && a.totalTokens === b.totalTokens && Math.abs(a.totalCost - b.totalCost) < 1e-4; } function isLowerTokenConflict(existing: DailyEntry, incoming: DailyEntry): boolean { return toNumber(incoming.totalTokens) < toNumber(existing.totalTokens); } function conflictMessage(label: string, conflict: MergeConflict): string { const existingTokens = formatNumber(toNumber(conflict.existing.totalTokens)); const incomingTokens = formatNumber(toNumber(conflict.incoming.totalTokens)); return `${label} ${colors.yellow}${conflict.date}${colors.reset}: existing ${colors.green}${existingTokens}${colors.reset} tokens, incoming ${colors.red}${incomingTokens}${colors.reset} tokens`; } /** * Merges daily entries from multiple sources into a single dataset * @param entries - Array of daily entries from different sources * @param baseByDate - Existing entries indexed by date * @returns Object with merge statistics and updated map */ function mergeEntries( entries: DailyEntry[], baseByDate: Map ): MergeStats { const stats: MergeStats = { added: [], replaced: [], unchanged: [], conflicts: [] }; for (const incoming of entries) { const existing = baseByDate.get(incoming.date); if (!existing) { baseByDate.set(incoming.date, incoming); stats.added.push(incoming.date); continue; } if (isReplacementBetter(existing, incoming)) { baseByDate.set(incoming.date, incoming); stats.replaced.push(incoming.date); continue; } if (isLowerTokenConflict(existing, incoming)) { stats.conflicts.push({ date: incoming.date, existing, incoming }); continue; } stats.unchanged.push(incoming.date); } return stats; } function resolveConflicts( label: string, stats: MergeStats, targetMap: Map, options: { interactive: boolean; dryRun: boolean; acceptLower: boolean } ): { resolved: string[]; skipped: string[] } { if (stats.conflicts.length === 0) { return { resolved: [], skipped: [] }; } const resolvedDates: string[] = []; const skippedDates: string[] = []; const remainingConflicts: MergeConflict[] = []; for (const conflict of stats.conflicts) { if (options.dryRun) { if (options.acceptLower) { log.dim(`[DRY RUN] Would replace ${conflictMessage(label, conflict)}`); resolvedDates.push(conflict.date); } else { log.dim(`[DRY RUN] Conflict detected - ${conflictMessage(label, conflict)} (kept existing)`); skippedDates.push(conflict.date); remainingConflicts.push(conflict); } continue; } if (options.acceptLower) { log.warn(`Conflict override (accept-lower) - ${conflictMessage(label, conflict)}`); targetMap.set(conflict.date, conflict.incoming); resolvedDates.push(conflict.date); continue; } if (!options.interactive) { log.warn(`Conflict detected - ${conflictMessage(label, conflict)} (kept existing)`); skippedDates.push(conflict.date); remainingConflicts.push(conflict); continue; } const response = prompt(`${colors.yellow}⚠${colors.reset} ${conflictMessage(label, conflict)}\n Replace existing entry? (y/N) `) ?? ""; if (YES_PATTERN.test(response.trim())) { log.success(`Conflict resolved - replaced ${conflict.date}`); targetMap.set(conflict.date, conflict.incoming); resolvedDates.push(conflict.date); } else { log.info(`Conflict kept - retained existing data for ${conflict.date}`); skippedDates.push(conflict.date); remainingConflicts.push(conflict); } } if (resolvedDates.length > 0) { stats.replaced.push(...resolvedDates); } stats.conflicts = remainingConflicts; return { resolved: resolvedDates, skipped: skippedDates }; } async function selectTotals( label: string, entries: DailyEntry[], storedTotals: Totals | undefined, options: { interactive: boolean; dryRun: boolean } ): Promise { const computedTotals = computeTotals(entries); if (!storedTotals) { return { totals: computedTotals, source: "computed", changed: false }; } if (totalsEqual(storedTotals, computedTotals)) { return { totals: computedTotals, source: "computed", changed: false }; } const storedIsZero = storedTotals.totalTokens === 0 && storedTotals.totalCost === 0; const computedIsHigher = computedTotals.totalTokens > storedTotals.totalTokens || computedTotals.totalCost > storedTotals.totalCost; if (storedIsZero || computedIsHigher) { if (!options.dryRun) { return { totals: computedTotals, source: "computed", changed: true }; } else { log.dim(`[DRY RUN] Would update ${label} totals to computed values.`); return { totals: storedTotals, source: "stored", changed: false }; } } const message = `${label} totals mismatch: stored ${formatTotals(storedTotals)} vs computed ${formatTotals(computedTotals)}`; if (options.dryRun) { log.dim(`[DRY RUN] ${message}. Keeping stored totals.`); return { totals: storedTotals, source: "stored", changed: false }; } if (!options.interactive) { return { totals: storedTotals, source: "stored", changed: false }; } const answer = prompt(`${colors.yellow}⚠${colors.reset} ${message}\n Update totals to computed values? (y/N) `) ?? ""; if (YES_PATTERN.test(answer.trim())) { log.success(`${label}: totals updated to computed values.`); return { totals: computedTotals, source: "computed", changed: true }; } log.info(`${label}: totals left as stored values.`); return { totals: storedTotals, source: "stored", changed: false }; } async function validateRequiredParams(command: string, args: string[]): Promise { if (command === "sync") { const nonFlagArgs = args.filter(arg => !arg.startsWith("-")); if (nonFlagArgs.length < 2) { log.error(`Missing required parameters for sync command`); console.log(`\n${colors.bright}Required parameters:${colors.reset}`); console.log(` JSON file containing usage data`); console.log(` Provider type: 'claude' or 'codex'\n`); console.log(`Use 'ccombine sync --help' for more information`); return false; } } return true; } async function main() { const args = process.argv.slice(2); if (args.length === 0) { printUsage(); process.exit(1); } if (args.includes("-h") || args.includes("--help")) { if (args.length === 1) { printUsage(); } else { const cmdArg = args.find(arg => !arg.startsWith("-")); if (cmdArg && commands[cmdArg]) { printCommandHelp(commands[cmdArg]); } else { printUsage(); } } process.exit(0); } const DEFAULT_BASE_PATH = resolvePath("public", "data", "cc.json"); const knownCommands = new Set(["auto", "sync", "init"]); let command: "auto" | "sync" | "init" | undefined; let commandArgs = args; // Determine command if (args.length > 0 && knownCommands.has(args[0])) { command = args[0] as "auto" | "sync" | "init"; commandArgs = args.slice(1); } else if (args[0] && !args[0].startsWith("-")) { log.error(`Unknown command: ${args[0]}`); console.log(`\nAvailable commands: ${Array.from(knownCommands).join(", ")}`); console.log(`Use 'ccombine --help' for more information`); process.exit(1); } else { log.error(`No command specified`); printUsage(); process.exit(1); } // Check for command-specific help if (commandArgs.includes("--help") || commandArgs.includes("-h")) { if (commands[command]) { printCommandHelp(commands[command]); process.exit(0); } } if (command === "init") { await handleInit(commandArgs, DEFAULT_BASE_PATH); return; } let basePath = DEFAULT_BASE_PATH; let outPath: string | undefined; let dryRun = false; let acceptLower = false; let cousageCmd = "bunx @ccusage/codex@latest --json"; let ccusageCmd = "bunx ccusage@latest --json"; let inputPath = ""; let provider: ProviderType | null = null; const expectValue = (currentArgs: string[], idx: number, option: string): [string, number] => { if (idx + 1 >= currentArgs.length) { log.error(`Missing value for ${option}`); console.log(`\nUse 'ccombine ${command} --help' for usage information`); process.exit(1); } return [currentArgs[idx + 1], idx + 1]; }; if (command === "auto") { for (let i = 0; i < commandArgs.length; i++) { const arg = commandArgs[i]; if (arg === "--base") { const [value, next] = expectValue(commandArgs, i, "--base"); basePath = resolvePath(value); i = next; } else if (arg === "--out") { const [value, next] = expectValue(commandArgs, i, "--out"); outPath = resolvePath(value); i = next; } else if (arg === "--cousage-cmd") { const [value, next] = expectValue(commandArgs, i, "--cousage-cmd"); cousageCmd = value; i = next; } else if (arg === "--ccusage-cmd") { const [value, next] = expectValue(commandArgs, i, "--ccusage-cmd"); ccusageCmd = value; i = next; } else if (arg === "--dry" || arg === "--dry-run") { dryRun = true; } else if (arg === "--accept-lower") { acceptLower = true; } else if (arg !== "--help" && arg !== "-h") { log.error(`Unknown option for auto: ${arg}`); console.log(`\nUse 'ccombine auto --help' for available options`); process.exit(1); } } } else if (command === "sync") { if (!(await validateRequiredParams("sync", commandArgs))) { process.exit(1); } inputPath = resolvePath(commandArgs[0]); const providerArg = commandArgs[1].toLowerCase(); if (providerArg !== "claude" && providerArg !== "codex") { log.error(`Invalid provider: '${commandArgs[1]}'`); console.log(`\nValid providers: 'claude' or 'codex'`); console.log(`Use 'ccombine sync --help' for more information`); process.exit(1); } provider = providerArg as ProviderType; for (let i = 2; i < commandArgs.length; i++) { const arg = commandArgs[i]; if (arg === "--base") { const [value, next] = expectValue(commandArgs, i, "--base"); basePath = resolvePath(value); i = next; } else if (arg === "--out") { const [value, next] = expectValue(commandArgs, i, "--out"); outPath = resolvePath(value); i = next; } else if (arg === "--dry" || arg === "--dry-run") { dryRun = true; } else if (arg === "--accept-lower") { acceptLower = true; } else if (arg !== "--help" && arg !== "-h") { log.error(`Unknown option for sync: ${arg}`); console.log(`\nUse 'ccombine sync --help' for available options`); process.exit(1); } } } else { log.error(`Unexpected error: unhandled command '${command}'`); process.exit(1); } if (command === "sync" && !(await fileExists(inputPath))) { log.error(`Input file not found: ${inputPath}`); console.log(`\nPlease check the file path and try again`); process.exit(1); } if (!outPath) { outPath = basePath; } const outputPath = outPath; const baseExists = await fileExists(basePath); const baseExtended: Partial = baseExists ? await readJson>(basePath) : {}; const claudeByDate = new Map(); for (const entry of extractProviderEntries(baseExtended?.claudeCode)) { claudeByDate.set(entry.date, entry); } const codexByDate = new Map(); for (const entry of extractProviderEntries(baseExtended?.codex)) { codexByDate.set(entry.date, entry); } const storedMainTotals = baseExtended.totals ? coerceTotals(baseExtended.totals) : undefined; const storedClaudeTotals = isObject(baseExtended?.claudeCode?.totals) ? coerceTotals(baseExtended.claudeCode?.totals as unknown) : undefined; const storedCodexTotals = isObject(baseExtended?.codex?.totals) ? coerceTotals(baseExtended.codex?.totals as unknown) : undefined; const interactivePrompts = process.stdout.isTTY && command === "sync" && !dryRun; const allStats = { added: 0, replaced: 0, unchanged: 0, conflictsResolved: 0, conflictsKept: 0 }; if (command === "auto") { const [codexData, ccData] = await Promise.all([ fetchCousageData(cousageCmd), fetchCcusageData(ccusageCmd), ]); if (codexData) { const codexDaily = codexData.daily.map(convertCodexToCc); const stats = mergeEntries(codexDaily, codexByDate); const conflictOutcome = resolveConflicts("Codex", stats, codexByDate, { interactive: false, dryRun, acceptLower, }); allStats.added += stats.added.length; allStats.replaced += stats.replaced.length; allStats.unchanged += stats.unchanged.length; allStats.conflictsResolved += conflictOutcome.resolved.length; allStats.conflictsKept += conflictOutcome.skipped.length; log.info(`Codex: Added ${colors.green}${stats.added.length}${colors.reset}, Replaced ${colors.yellow}${stats.replaced.length}${colors.reset}, Unchanged ${colors.dim}${stats.unchanged.length}${colors.reset}`); if (conflictOutcome.resolved.length || conflictOutcome.skipped.length) { log.info(`Codex: Conflicts resolved ${colors.green}${conflictOutcome.resolved.length}${colors.reset}, kept ${colors.yellow}${conflictOutcome.skipped.length}${colors.reset}`); } } else { log.warn("Failed to fetch Codex data - continuing with existing data"); } if (ccData) { const claudeDaily = ccData.daily; const stats = mergeEntries(claudeDaily, claudeByDate); const conflictOutcome = resolveConflicts("Claude Code", stats, claudeByDate, { interactive: false, dryRun, acceptLower, }); allStats.added += stats.added.length; allStats.replaced += stats.replaced.length; allStats.unchanged += stats.unchanged.length; allStats.conflictsResolved += conflictOutcome.resolved.length; allStats.conflictsKept += conflictOutcome.skipped.length; log.info(`Claude Code: Added ${colors.green}${stats.added.length}${colors.reset}, Replaced ${colors.yellow}${stats.replaced.length}${colors.reset}, Unchanged ${colors.dim}${stats.unchanged.length}${colors.reset}`); if (conflictOutcome.resolved.length || conflictOutcome.skipped.length) { log.info(`Claude Code: Conflicts resolved ${colors.green}${conflictOutcome.resolved.length}${colors.reset}, kept ${colors.yellow}${conflictOutcome.skipped.length}${colors.reset}`); } } else { log.warn("Failed to fetch Claude Code data - continuing with existing data"); } } else if (command === "sync") { if (!provider) { log.error("Missing provider for sync command"); process.exit(1); } const syncProvider = provider; const rawData = await readJson(inputPath); const newEntries = (syncProvider === "codex" && isCodexFile(rawData)) ? rawData.daily.map(convertCodexToCc) : normalizeCcShape(rawData).daily; const providerMap = syncProvider === "claude" ? claudeByDate : codexByDate; const stats = mergeEntries(newEntries, providerMap); const label = syncProvider === "claude" ? "Claude Code" : "Codex"; const conflictOutcome = resolveConflicts(label, stats, providerMap, { interactive: interactivePrompts, dryRun, acceptLower, }); allStats.added += stats.added.length; allStats.replaced += stats.replaced.length; allStats.unchanged += stats.unchanged.length; allStats.conflictsResolved += conflictOutcome.resolved.length; allStats.conflictsKept += conflictOutcome.skipped.length; log.info(`${label}: Added ${colors.green}${stats.added.length}${colors.reset}, Replaced ${colors.yellow}${stats.replaced.length}${colors.reset}, Unchanged ${colors.dim}${stats.unchanged.length}${colors.reset}`); if (conflictOutcome.resolved.length || conflictOutcome.skipped.length) { log.info(`${label}: Conflicts resolved ${colors.green}${conflictOutcome.resolved.length}${colors.reset}, kept ${colors.yellow}${conflictOutcome.skipped.length}${colors.reset}`); } } const merged: ExtendedCcFile = {}; const allDates = new Set(); const combinedByDate = new Map(); for (const date of claudeByDate.keys()) allDates.add(date); for (const date of codexByDate.keys()) allDates.add(date); for (const date of allDates) { const claudeEntry = claudeByDate.get(date); const codexEntry = codexByDate.get(date); const combined: DailyEntry = { date, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0, modelsUsed: [], modelBreakdowns: [], }; if (claudeEntry) { combined.inputTokens += claudeEntry.inputTokens; combined.outputTokens += claudeEntry.outputTokens; combined.cacheCreationTokens += claudeEntry.cacheCreationTokens; combined.cacheReadTokens += claudeEntry.cacheReadTokens; combined.totalTokens += claudeEntry.totalTokens; combined.totalCost += claudeEntry.totalCost; if (claudeEntry.modelsUsed && combined.modelsUsed) combined.modelsUsed.push(...claudeEntry.modelsUsed); if (claudeEntry.modelBreakdowns && combined.modelBreakdowns) combined.modelBreakdowns.push(...claudeEntry.modelBreakdowns); } if (codexEntry) { combined.inputTokens += codexEntry.inputTokens; combined.outputTokens += codexEntry.outputTokens; combined.cacheCreationTokens += codexEntry.cacheCreationTokens; combined.cacheReadTokens += codexEntry.cacheReadTokens; combined.totalTokens += codexEntry.totalTokens; combined.totalCost += codexEntry.totalCost; if (codexEntry.modelsUsed && combined.modelsUsed) combined.modelsUsed.push(...codexEntry.modelsUsed); if (codexEntry.modelBreakdowns && combined.modelBreakdowns) combined.modelBreakdowns.push(...codexEntry.modelBreakdowns); } combinedByDate.set(date, combined); } const combinedDaily = sortByDateAsc(Array.from(combinedByDate.values())); if (combinedDaily.length > 0) { const mainTotalsDecision = await selectTotals("Combined", combinedDaily, storedMainTotals, { interactive: interactivePrompts, dryRun, }); merged.totals = mainTotalsDecision.totals; } const claudeEntries = sortByDateAsc(Array.from(claudeByDate.values())); if (claudeEntries.length > 0) { const claudeTotalsDecision = await selectTotals("Claude Code", claudeEntries, storedClaudeTotals, { interactive: interactivePrompts, dryRun, }); merged.claudeCode = { daily: claudeEntries, totals: claudeTotalsDecision.totals, }; } const codexEntries = sortByDateAsc(Array.from(codexByDate.values())); if (codexEntries.length > 0) { const codexTotalsDecision = await selectTotals("Codex", codexEntries, storedCodexTotals, { interactive: interactivePrompts, dryRun, }); merged.codex = { daily: codexEntries, totals: codexTotalsDecision.totals, }; } if (dryRun) { if (merged.totals) { log.dim(`[DRY RUN] Combined totals from ${combinedDaily.length} unique dates`); } if (claudeEntries.length > 0) { log.dim(`[DRY RUN] Claude Code entries: ${claudeEntries.length}`); } if (codexEntries.length > 0) { log.dim(`[DRY RUN] Codex entries: ${codexEntries.length}`); } } else { await ensureDirectoryFor(outputPath); await Bun.write(outputPath, JSON.stringify(merged, null, 2) + "\n"); log.success(`Successfully wrote merged data to ${outputPath}`); } if (!dryRun) { console.log(`\n${colors.bright}Summary:${colors.reset}`); console.log(` Added: ${colors.green}${allStats.added}${colors.reset}`); console.log(` Replaced: ${colors.yellow}${allStats.replaced}${colors.reset}`); console.log(` Unchanged: ${colors.dim}${allStats.unchanged}${colors.reset}`); if (allStats.conflictsResolved > 0 || allStats.conflictsKept > 0) { console.log(` Conflicts: ${colors.green}${allStats.conflictsResolved} resolved${colors.reset}, ${colors.yellow}${allStats.conflictsKept} kept${colors.reset}`); } } } main().catch((err) => { log.error(`Fatal error: ${err?.message || err}`); if (process.env.DEBUG) { console.error(err); } process.exit(1); });