Skip to content

Commit 910f5cf

Browse files
committed
Add cost tracking and success celebration to --green
Implement cost transparency and success celebration features: - CostTracker class to track token usage and costs - Storage in ~/.claude/ for cross-repo stats (stats.json, streak.json) - Storage in .claude/ for repo-specific data (snapshots, sessions) - celebrateSuccess() shows costs, stats, and success streak - Cleanup old data using del package (RETENTION constants) - Initialize storage and cleanup at --green start - Track fixes and show summary on CI success
1 parent f383083 commit 910f5cf

File tree

1 file changed

+292
-1
lines changed

1 file changed

+292
-1
lines changed

scripts/claude.mjs

Lines changed: 292 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import { spawn } from 'node:child_process'
88
import crypto from 'node:crypto'
99
import { existsSync, promises as fs } from 'node:fs'
10+
import os from 'node:os'
1011
import path from 'node:path'
1112
import { fileURLToPath } from 'node:url'
1213

14+
import { deleteAsync as del } from 'del'
1315
import colors from 'yoctocolors-cjs'
1416

1517
import { parseArgs } from '@socketsecurity/lib/argv/parse'
@@ -29,6 +31,59 @@ const SOCKET_PROJECTS = [
2931
'socket-registry',
3032
]
3133

34+
// Storage paths.
35+
// User-level (cross-repo, persistent)
36+
const CLAUDE_HOME = path.join(os.homedir(), '.claude')
37+
const STORAGE_PATHS = {
38+
fixMemory: path.join(CLAUDE_HOME, 'fix-memory.db'),
39+
stats: path.join(CLAUDE_HOME, 'stats.json'),
40+
history: path.join(CLAUDE_HOME, 'history.json'),
41+
config: path.join(CLAUDE_HOME, 'config.json'),
42+
cache: path.join(CLAUDE_HOME, 'cache'),
43+
}
44+
45+
// Repo-level (per-project, temporary)
46+
const REPO_STORAGE = {
47+
snapshots: path.join(claudeDir, 'snapshots'),
48+
session: path.join(claudeDir, 'session.json'),
49+
scratch: path.join(claudeDir, 'scratch'),
50+
}
51+
52+
// Retention periods (milliseconds).
53+
const RETENTION = {
54+
// 7 days
55+
snapshots: 7 * 24 * 60 * 60 * 1000,
56+
// 30 days
57+
cache: 30 * 24 * 60 * 60 * 1000,
58+
// 1 day
59+
sessions: 24 * 60 * 60 * 1000,
60+
}
61+
62+
// Claude API pricing (USD per token).
63+
// https://www.anthropic.com/pricing
64+
const PRICING = {
65+
'claude-sonnet-4-5': {
66+
// $3 per 1M input tokens
67+
input: 3.0 / 1_000_000,
68+
// $15 per 1M output tokens
69+
output: 15.0 / 1_000_000,
70+
// $3.75 per 1M cache write tokens
71+
cache_write: 3.75 / 1_000_000,
72+
// $0.30 per 1M cache read tokens
73+
cache_read: 0.3 / 1_000_000,
74+
},
75+
'claude-sonnet-3-7': {
76+
// $3 per 1M input tokens
77+
input: 3.0 / 1_000_000,
78+
// $15 per 1M output tokens
79+
output: 15.0 / 1_000_000,
80+
// $3.75 per 1M cache write tokens
81+
cache_write: 3.75 / 1_000_000,
82+
// $0.30 per 1M cache read tokens
83+
cache_read: 0.3 / 1_000_000,
84+
},
85+
}
86+
3287
// Simple inline logger.
3388
const log = {
3489
info: msg => console.log(msg),
@@ -64,6 +119,228 @@ function printFooter(message) {
64119
}
65120
}
66121

122+
/**
123+
* Initialize storage directories.
124+
*/
125+
async function initStorage() {
126+
await fs.mkdir(CLAUDE_HOME, { recursive: true })
127+
await fs.mkdir(STORAGE_PATHS.cache, { recursive: true })
128+
await fs.mkdir(REPO_STORAGE.snapshots, { recursive: true })
129+
await fs.mkdir(REPO_STORAGE.scratch, { recursive: true })
130+
}
131+
132+
/**
133+
* Clean up old data using del package.
134+
*/
135+
async function cleanupOldData() {
136+
const now = Date.now()
137+
138+
// Clean old snapshots in current repo.
139+
try {
140+
const snapshots = await fs.readdir(REPO_STORAGE.snapshots)
141+
const toDelete = []
142+
for (const snap of snapshots) {
143+
const snapPath = path.join(REPO_STORAGE.snapshots, snap)
144+
const stats = await fs.stat(snapPath)
145+
if (now - stats.mtime.getTime() > RETENTION.snapshots) {
146+
toDelete.push(snapPath)
147+
}
148+
}
149+
if (toDelete.length > 0) {
150+
// Force delete temp directories outside CWD.
151+
await del(toDelete, { force: true })
152+
}
153+
} catch {
154+
// Ignore errors if directory doesn't exist.
155+
}
156+
157+
// Clean old cache entries in ~/.claude/cache/.
158+
try {
159+
const cached = await fs.readdir(STORAGE_PATHS.cache)
160+
const toDelete = []
161+
for (const file of cached) {
162+
const filePath = path.join(STORAGE_PATHS.cache, file)
163+
const stats = await fs.stat(filePath)
164+
if (now - stats.mtime.getTime() > RETENTION.cache) {
165+
toDelete.push(filePath)
166+
}
167+
}
168+
if (toDelete.length > 0) {
169+
// Force delete temp directories outside CWD.
170+
await del(toDelete, { force: true })
171+
}
172+
} catch {
173+
// Ignore errors if directory doesn't exist.
174+
}
175+
}
176+
177+
/**
178+
* Cost tracking with budget controls.
179+
*/
180+
class CostTracker {
181+
constructor(model = 'claude-sonnet-4-5') {
182+
this.model = model
183+
this.session = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }
184+
this.monthly = this.loadMonthlyStats()
185+
this.startTime = Date.now()
186+
}
187+
188+
loadMonthlyStats() {
189+
try {
190+
if (existsSync(STORAGE_PATHS.stats)) {
191+
const data = JSON.parse(fs.readFileSync(STORAGE_PATHS.stats, 'utf8'))
192+
// YYYY-MM
193+
const currentMonth = new Date().toISOString().slice(0, 7)
194+
if (data.month === currentMonth) {
195+
return data
196+
}
197+
}
198+
} catch {
199+
// Ignore errors, start fresh.
200+
}
201+
return {
202+
month: new Date().toISOString().slice(0, 7),
203+
cost: 0,
204+
fixes: 0,
205+
sessions: 0,
206+
}
207+
}
208+
209+
saveMonthlyStats() {
210+
try {
211+
fs.writeFileSync(
212+
STORAGE_PATHS.stats,
213+
JSON.stringify(this.monthly, null, 2),
214+
)
215+
} catch {
216+
// Ignore errors.
217+
}
218+
}
219+
220+
track(usage) {
221+
const pricing = PRICING[this.model]
222+
if (!pricing) {
223+
return
224+
}
225+
226+
const inputTokens = usage.input_tokens || 0
227+
const outputTokens = usage.output_tokens || 0
228+
const cacheWriteTokens = usage.cache_creation_input_tokens || 0
229+
const cacheReadTokens = usage.cache_read_input_tokens || 0
230+
231+
const cost =
232+
inputTokens * pricing.input +
233+
outputTokens * pricing.output +
234+
cacheWriteTokens * pricing.cache_write +
235+
cacheReadTokens * pricing.cache_read
236+
237+
this.session.input += inputTokens
238+
this.session.output += outputTokens
239+
this.session.cacheWrite += cacheWriteTokens
240+
this.session.cacheRead += cacheReadTokens
241+
this.session.cost += cost
242+
243+
this.monthly.cost += cost
244+
this.saveMonthlyStats()
245+
}
246+
247+
showSessionSummary() {
248+
const duration = Date.now() - this.startTime
249+
console.log(colors.cyan('\n💰 Cost Summary:'))
250+
console.log(` Input tokens: ${this.session.input.toLocaleString()}`)
251+
console.log(` Output tokens: ${this.session.output.toLocaleString()}`)
252+
if (this.session.cacheWrite > 0) {
253+
console.log(` Cache write: ${this.session.cacheWrite.toLocaleString()}`)
254+
}
255+
if (this.session.cacheRead > 0) {
256+
console.log(` Cache read: ${this.session.cacheRead.toLocaleString()}`)
257+
}
258+
console.log(
259+
` Session cost: ${colors.green(`$${this.session.cost.toFixed(4)}`)}`,
260+
)
261+
console.log(
262+
` Monthly total: ${colors.yellow(`$${this.monthly.cost.toFixed(2)}`)}`,
263+
)
264+
console.log(` Duration: ${colors.gray(formatDuration(duration))}`)
265+
}
266+
}
267+
268+
/**
269+
* Format duration in human-readable form.
270+
*/
271+
function formatDuration(ms) {
272+
const seconds = Math.floor(ms / 1000)
273+
const minutes = Math.floor(seconds / 60)
274+
const hours = Math.floor(minutes / 60)
275+
276+
if (hours > 0) {
277+
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
278+
}
279+
if (minutes > 0) {
280+
return `${minutes}m ${seconds % 60}s`
281+
}
282+
return `${seconds}s`
283+
}
284+
285+
/**
286+
* Success celebration with stats.
287+
*/
288+
async function celebrateSuccess(costTracker, stats = {}) {
289+
const messages = [
290+
"🎉 CI is green! You're a legend!",
291+
"✨ All tests passed! Claude's got your back!",
292+
'🚀 Ship it! CI is happy!',
293+
'💚 Green as a well-tested cucumber!',
294+
'🏆 Victory! All checks passed!',
295+
'⚡ Flawless execution! CI approved!',
296+
]
297+
298+
const message = messages[Math.floor(Math.random() * messages.length)]
299+
log.success(message)
300+
301+
// Show session stats.
302+
if (costTracker) {
303+
costTracker.showSessionSummary()
304+
}
305+
306+
// Show fix details if available.
307+
if (stats.fixCount > 0) {
308+
console.log(colors.cyan('\n📊 Session Stats:'))
309+
console.log(` Fixes applied: ${stats.fixCount}`)
310+
console.log(` Retries: ${stats.retries || 0}`)
311+
}
312+
313+
// Update success streak.
314+
try {
315+
const streakPath = path.join(CLAUDE_HOME, 'streak.json')
316+
let streak = { current: 0, best: 0, lastSuccess: null }
317+
if (existsSync(streakPath)) {
318+
streak = JSON.parse(await fs.readFile(streakPath, 'utf8'))
319+
}
320+
321+
const now = Date.now()
322+
const oneDayAgo = now - 24 * 60 * 60 * 1000
323+
324+
// Reset streak if last success was more than 24h ago.
325+
if (streak.lastSuccess && streak.lastSuccess < oneDayAgo) {
326+
streak.current = 1
327+
} else {
328+
streak.current += 1
329+
}
330+
331+
streak.best = Math.max(streak.best, streak.current)
332+
streak.lastSuccess = now
333+
334+
await fs.writeFile(streakPath, JSON.stringify(streak, null, 2))
335+
336+
console.log(colors.cyan('\n🔥 Success Streak:'))
337+
console.log(` Current: ${streak.current}`)
338+
console.log(` Best: ${streak.best}`)
339+
} catch {
340+
// Ignore errors.
341+
}
342+
}
343+
67344
async function runCommand(command, args = [], options = {}) {
68345
const opts = { __proto__: null, ...options }
69346
return new Promise((resolve, reject) => {
@@ -3047,6 +3324,14 @@ async function runGreen(claudeCmd, options = {}) {
30473324
)
30483325
const useNoVerify = opts['no-verify'] === true
30493326

3327+
// Initialize storage and cleanup old data.
3328+
await initStorage()
3329+
await cleanupOldData()
3330+
3331+
// Initialize cost tracker.
3332+
const costTracker = new CostTracker()
3333+
let fixCount = 0
3334+
30503335
printHeader('Green CI Pipeline')
30513336

30523337
// Track errors to avoid checking same error repeatedly
@@ -3291,6 +3576,7 @@ Let's work through this together to get CI passing.`
32913576
await runCommand('git', commitArgs, {
32923577
cwd: rootPath,
32933578
})
3579+
fixCount++
32943580

32953581
// Validate before pushing
32963582
const validation = await validateBeforePush(rootPath)
@@ -3534,7 +3820,10 @@ Let's work through this together to get CI passing.`
35343820

35353821
if (run.status === 'completed') {
35363822
if (run.conclusion === 'success') {
3537-
log.done('CI workflow passed! 🎉')
3823+
await celebrateSuccess(costTracker, {
3824+
fixCount,
3825+
retries: pollAttempt,
3826+
})
35383827
printFooter('Green CI Pipeline complete!')
35393828
return true
35403829
}
@@ -3803,6 +4092,7 @@ Fix all issues by making necessary file changes. Be direct, don't ask questions.
38034092
})
38044093

38054094
if (commitResult.exitCode === 0) {
4095+
fixCount++
38064096
// Push the commits
38074097
await runCommand('git', ['push'], { cwd: rootPath })
38084098
log.done('Pushed fix commits')
@@ -4124,6 +4414,7 @@ Fix the issue by making necessary file changes. Be direct, don't ask questions.`
41244414
)
41254415

41264416
if (commitResult.exitCode === 0) {
4417+
fixCount++
41274418
log.done(`Committed fix for ${job.name}`)
41284419
hasPendingCommits = true
41294420
} else {

0 commit comments

Comments
 (0)