cost-claude
Version:
Claude Code cost monitoring, analytics, and optimization toolkit
283 lines ⢠12.3 kB
JavaScript
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { createHash } from 'crypto';
import chalk from 'chalk';
import { logger } from '../utils/logger.js';
import { JSONLParser } from '../core/jsonl-parser.js';
export class SyncManager {
syncDir;
machineId;
machineName;
parser;
constructor() {
this.syncDir = join(homedir(), '.cost-claude', 'sync');
this.machineId = this.generateMachineId();
this.machineName = this.getMachineName();
this.parser = new JSONLParser();
if (!existsSync(this.syncDir)) {
mkdirSync(this.syncDir, { recursive: true });
}
}
generateMachineId() {
const hostname = require('os').hostname();
const platform = process.platform;
const hash = createHash('sha256').update(`${hostname}-${platform}`).digest('hex');
return hash.substring(0, 8);
}
getMachineName() {
return require('os').hostname();
}
async exportForSync(projectPath, outputPath) {
try {
const messages = await this.parser.parseDirectory(projectPath);
const totalCost = messages.reduce((sum, msg) => sum + (msg.costUSD || 0), 0);
const checksum = this.calculateChecksum(messages);
const metadata = {
machineId: this.machineId,
machineName: this.machineName,
lastSync: new Date().toISOString(),
messagesCount: messages.length,
totalCost,
checksum
};
const exportData = {
metadata,
messages: messages.map((msg) => ({
...msg,
_syncMachineId: this.machineId,
_syncTimestamp: new Date().toISOString()
}))
};
const filename = `claude-sync-${this.machineId}-${Date.now()}.json`;
const finalPath = outputPath || join(this.syncDir, filename);
writeFileSync(finalPath, JSON.stringify(exportData, null, 2));
logger.info(`Exported ${messages.length} messages to ${finalPath}`);
return finalPath;
}
catch (error) {
logger.error('Error exporting for sync:', error);
throw error;
}
}
async importAndMerge(importPaths, targetPath, options = { strategy: 'newest', backup: true, dryRun: false }) {
try {
const existingMessages = await this.parser.parseDirectory(targetPath);
const existingMap = new Map();
existingMessages.forEach((msg) => {
if (msg.uuid) {
existingMap.set(msg.uuid, msg);
}
});
const importedData = [];
let totalNewMessages = 0;
let totalDuplicates = 0;
let totalConflicts = 0;
for (const importPath of importPaths) {
if (!existsSync(importPath)) {
logger.warn(`Import file not found: ${importPath}`);
continue;
}
const data = JSON.parse(readFileSync(importPath, 'utf-8'));
importedData.push(data);
for (const msg of data.messages) {
if (!msg.uuid)
continue;
if (!existingMap.has(msg.uuid)) {
existingMap.set(msg.uuid, msg);
totalNewMessages++;
}
else {
const existing = existingMap.get(msg.uuid);
if (this.messagesEqual(existing, msg)) {
totalDuplicates++;
}
else {
totalConflicts++;
const winner = this.resolveConflict(existing, msg, options.strategy);
existingMap.set(msg.uuid, winner);
}
}
}
}
const mergedMessages = Array.from(existingMap.values());
const totalCost = mergedMessages.reduce((sum, msg) => sum + (msg.costUSD || 0), 0);
const report = {
totalMessages: mergedMessages.length,
newMessages: totalNewMessages,
duplicates: totalDuplicates,
conflicts: totalConflicts,
totalCost,
machines: importedData.map(d => d.metadata)
};
if (!options.dryRun) {
if (options.backup && existingMessages.length > 0) {
const backupPath = join(targetPath, '..', `backup-${Date.now()}.jsonl`);
await this.createBackup(targetPath, backupPath);
}
await this.writeMergedData(mergedMessages, targetPath);
}
return report;
}
catch (error) {
logger.error('Error importing and merging:', error);
throw error;
}
}
calculateChecksum(messages) {
const sortedMessages = [...messages].sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
const data = sortedMessages.map(msg => `${msg.uuid}:${msg.timestamp}:${msg.costUSD || 0}`).join('|');
return createHash('sha256').update(data).digest('hex').substring(0, 16);
}
messagesEqual(msg1, msg2) {
return (msg1.uuid === msg2.uuid &&
msg1.timestamp === msg2.timestamp &&
msg1.costUSD === msg2.costUSD &&
msg1.type === msg2.type);
}
resolveConflict(existing, incoming, strategy) {
switch (strategy) {
case 'newest':
return new Date(existing.timestamp || 0) > new Date(incoming.timestamp || 0)
? existing : incoming;
case 'oldest':
return new Date(existing.timestamp || 0) < new Date(incoming.timestamp || 0)
? existing : incoming;
case 'cost':
if (existing.costUSD && !incoming.costUSD)
return existing;
if (!existing.costUSD && incoming.costUSD)
return incoming;
return (existing.message && typeof existing.message === 'object') ? existing : incoming;
case 'manual':
logger.warn(`Conflict for message ${existing.uuid}, using existing version`);
return existing;
default:
return existing;
}
}
async createBackup(sourcePath, backupPath) {
try {
const messages = await this.parser.parseDirectory(sourcePath);
const jsonlContent = messages.map((msg) => JSON.stringify(msg)).join('\n');
writeFileSync(backupPath, jsonlContent);
logger.info(`Backup created at ${backupPath}`);
}
catch (error) {
logger.error('Error creating backup:', error);
throw error;
}
}
async writeMergedData(messages, targetPath) {
try {
const fileGroups = new Map();
messages.forEach(msg => {
const date = msg.timestamp ? new Date(msg.timestamp).toISOString().split('T')[0] : 'unknown';
const key = `claude-${date}.jsonl`;
if (!fileGroups.has(key)) {
fileGroups.set(key, []);
}
fileGroups.get(key).push(msg);
});
fileGroups.forEach((msgs, filename) => {
const filePath = join(targetPath, filename);
const content = msgs.map(msg => JSON.stringify(msg)).join('\n');
writeFileSync(filePath, content);
});
logger.info(`Merged data written to ${targetPath}`);
}
catch (error) {
logger.error('Error writing merged data:', error);
throw error;
}
}
async compareWithRemote(localPath, remotePath) {
try {
const localMessages = await this.parser.parseDirectory(localPath);
const remoteMessages = await this.parser.parseDirectory(remotePath);
const localMap = new Map(localMessages.map((msg) => [msg.uuid, msg]));
const remoteMap = new Map(remoteMessages.map((msg) => [msg.uuid, msg]));
const localOnly = localMessages.filter((msg) => !remoteMap.has(msg.uuid));
const remoteOnly = remoteMessages.filter((msg) => !localMap.has(msg.uuid));
const conflicts = [];
localMessages.forEach((msg) => {
if (remoteMap.has(msg.uuid) && !this.messagesEqual(msg, remoteMap.get(msg.uuid))) {
conflicts.push(msg);
}
});
console.log(chalk.bold('\nš Sync Comparison Report'));
console.log('='.repeat(50));
console.log(`Local messages: ${localMessages.length}`);
console.log(`Remote messages: ${remoteMessages.length}`);
console.log(`Local only: ${localOnly.length}`);
console.log(`Remote only: ${remoteOnly.length}`);
console.log(`Conflicts: ${conflicts.length}`);
if (localOnly.length > 0) {
console.log(chalk.yellow('\nš¤ Messages only in local:'));
localOnly.slice(0, 5).forEach(msg => {
console.log(` - ${msg.timestamp} (${msg.costUSD ? `$${msg.costUSD.toFixed(2)}` : 'no cost'})`);
});
if (localOnly.length > 5) {
console.log(` ... and ${localOnly.length - 5} more`);
}
}
if (remoteOnly.length > 0) {
console.log(chalk.blue('\nš„ Messages only in remote:'));
remoteOnly.slice(0, 5).forEach(msg => {
console.log(` - ${msg.timestamp} (${msg.costUSD ? `$${msg.costUSD.toFixed(2)}` : 'no cost'})`);
});
if (remoteOnly.length > 5) {
console.log(` ... and ${remoteOnly.length - 5} more`);
}
}
if (conflicts.length > 0) {
console.log(chalk.red('\nā ļø Conflicting messages:'));
conflicts.slice(0, 5).forEach(msg => {
console.log(` - ${msg.uuid} at ${msg.timestamp}`);
});
if (conflicts.length > 5) {
console.log(` ... and ${conflicts.length - 5} more`);
}
}
}
catch (error) {
logger.error('Error comparing with remote:', error);
throw error;
}
}
formatSyncReport(report) {
const lines = [
chalk.bold('š Sync Report'),
'='.repeat(50),
'',
chalk.green(`ā
Total messages after sync: ${report.totalMessages}`),
chalk.blue(`š„ New messages imported: ${report.newMessages}`),
chalk.yellow(`š Duplicate messages skipped: ${report.duplicates}`),
];
if (report.conflicts > 0) {
lines.push(chalk.red(`ā ļø Conflicts resolved: ${report.conflicts}`));
}
lines.push(chalk.cyan(`š° Total cost: $${report.totalCost.toFixed(2)}`));
lines.push('');
lines.push(chalk.bold('š± Synced Machines:'));
report.machines.forEach(machine => {
lines.push(` ${machine.machineName} (${machine.machineId})`);
lines.push(` Last sync: ${new Date(machine.lastSync).toLocaleString()}`);
lines.push(` Messages: ${machine.messagesCount}`);
lines.push(` Cost: $${machine.totalCost.toFixed(2)}`);
lines.push('');
});
return lines.join('\n');
}
listAvailableSyncs() {
try {
const files = readdirSync(this.syncDir);
return files.filter(f => f.startsWith('claude-sync-') && f.endsWith('.json'));
}
catch (error) {
logger.error('Error listing sync files:', error);
return [];
}
}
}
//# sourceMappingURL=sync-manager.js.map