1208 lines
No EOL
42 KiB
TypeScript
1208 lines
No EOL
42 KiB
TypeScript
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<string, CodexModelData>;
|
|
}
|
|
|
|
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<string, CommandInfo> = {
|
|
auto: {
|
|
name: "auto",
|
|
description: "Automatically fetch and merge usage data from ccusage and cousage CLIs",
|
|
usage: "ccombine auto [options]",
|
|
options: [
|
|
{ flag: "--base <path>", description: "Base cc.json file to merge with (default: public/data/cc.json)" },
|
|
{ flag: "--out <path>", description: "Output file path (default: same as base)" },
|
|
{ flag: "--ccusage-cmd <cmd>", description: "Custom ccusage command (default: bunx ccusage@latest --json)" },
|
|
{ flag: "--cousage-cmd <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 <file> <provider> [options]",
|
|
options: [
|
|
{ flag: "<file>", description: "JSON file containing usage data to merge", required: true },
|
|
{ flag: "<provider>", description: "Provider type: 'claude' or 'codex'", required: true },
|
|
{ flag: "--base <path>", description: "Base cc.json file to merge with (default: public/data/cc.json)" },
|
|
{ flag: "--out <path>", 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 <path>", 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<T = unknown>(filePath: string): Promise<T> {
|
|
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<boolean> {
|
|
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<string, unknown> {
|
|
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 | undefined>): 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<void> {
|
|
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 <command> [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 <command> --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<void> {
|
|
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<string, string> = {
|
|
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<CodexFile | null> {
|
|
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<CcFile | null> {
|
|
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<string, DailyEntry>
|
|
): 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<string, DailyEntry>,
|
|
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<TotalsDecision> {
|
|
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<boolean> {
|
|
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(` <file> JSON file containing usage data`);
|
|
console.log(` <provider> 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<ExtendedCcFile> = baseExists ? await readJson<Partial<ExtendedCcFile>>(basePath) : {};
|
|
|
|
const claudeByDate = new Map<string, DailyEntry>();
|
|
for (const entry of extractProviderEntries(baseExtended?.claudeCode)) {
|
|
claudeByDate.set(entry.date, entry);
|
|
}
|
|
|
|
const codexByDate = new Map<string, DailyEntry>();
|
|
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<unknown>(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<string>();
|
|
const combinedByDate = new Map<string, DailyEntry>();
|
|
|
|
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);
|
|
}); |