tokengolf 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +36 -0
- package/CLAUDE.md +320 -0
- package/README.md +235 -0
- package/dist/cli.js +897 -0
- package/hooks/post-tool-use.js +43 -0
- package/hooks/pre-compact.js +35 -0
- package/hooks/session-end.js +172 -0
- package/hooks/session-start.js +100 -0
- package/hooks/session-stop.js +25 -0
- package/hooks/statusline.sh +72 -0
- package/hooks/user-prompt-submit.js +29 -0
- package/package.json +27 -0
- package/src/cli.js +115 -0
- package/src/components/ActiveRun.js +85 -0
- package/src/components/ScoreCard.js +157 -0
- package/src/components/StartRun.js +156 -0
- package/src/components/StatsView.js +112 -0
- package/src/lib/cost.js +149 -0
- package/src/lib/install.js +163 -0
- package/src/lib/score.js +330 -0
- package/src/lib/state.js +35 -0
- package/src/lib/store.js +76 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
|
|
8
|
+
let input = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
11
|
+
process.stdin.on('end', () => {
|
|
12
|
+
try {
|
|
13
|
+
const run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
14
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
15
|
+
|
|
16
|
+
const event = JSON.parse(input);
|
|
17
|
+
const toolName = event.tool_name || 'Unknown';
|
|
18
|
+
const toolCalls = run.toolCalls || {};
|
|
19
|
+
toolCalls[toolName] = (toolCalls[toolName] || 0) + 1;
|
|
20
|
+
|
|
21
|
+
const updated = {
|
|
22
|
+
...run,
|
|
23
|
+
toolCalls,
|
|
24
|
+
totalToolCalls: (run.totalToolCalls || 0) + 1,
|
|
25
|
+
};
|
|
26
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(updated, null, 2));
|
|
27
|
+
|
|
28
|
+
// Warn at 80%+
|
|
29
|
+
const pct = updated.spent / updated.budget;
|
|
30
|
+
if (pct >= 0.8 && pct < 1.0) {
|
|
31
|
+
const remaining = (updated.budget - updated.spent).toFixed(4);
|
|
32
|
+
process.stdout.write(JSON.stringify({
|
|
33
|
+
hookSpecificOutput: {
|
|
34
|
+
hookEventName: 'PostToolUse',
|
|
35
|
+
systemMessage: `⚠️ TokenGolf: $${updated.spent.toFixed(4)} of $${updated.budget.toFixed(2)} spent (${Math.round(pct * 100)}%). Only $${remaining} left. Be concise and targeted.`,
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// silent fail
|
|
41
|
+
}
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
let stdin = '';
|
|
10
|
+
try { stdin = fs.readFileSync('/dev/stdin', 'utf8'); } catch {}
|
|
11
|
+
|
|
12
|
+
let event = {};
|
|
13
|
+
try { event = JSON.parse(stdin); } catch {}
|
|
14
|
+
|
|
15
|
+
const trigger = event.trigger || 'auto'; // 'manual' or 'auto'
|
|
16
|
+
|
|
17
|
+
let run = null;
|
|
18
|
+
try { run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch {}
|
|
19
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
20
|
+
|
|
21
|
+
const compactionEvents = run.compactionEvents || [];
|
|
22
|
+
compactionEvents.push({
|
|
23
|
+
trigger,
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
contextPct: event.context_window?.used_percentage ?? event.context_window_usage_pct ?? null,
|
|
26
|
+
customInstructions: event.custom_instructions || null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
run = { ...run, compactionEvents };
|
|
30
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(run, null, 2));
|
|
31
|
+
} catch {
|
|
32
|
+
// silent fail
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
process.exit(0);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
|
|
6
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const { autoDetectCost } = await import(path.join(__dir, '../src/lib/cost.js'));
|
|
8
|
+
const { getCurrentRun, clearCurrentRun } = await import(path.join(__dir, '../src/lib/state.js'));
|
|
9
|
+
const { saveRun } = await import(path.join(__dir, '../src/lib/store.js'));
|
|
10
|
+
const { getTier, getModelClass, getEffortLevel, getEfficiencyRating, getBudgetPct } = await import(path.join(__dir, '../src/lib/score.js'));
|
|
11
|
+
|
|
12
|
+
function writeTTY(text) {
|
|
13
|
+
try {
|
|
14
|
+
const ttyFd = fs.openSync('/dev/tty', 'w');
|
|
15
|
+
fs.writeSync(ttyFd, text);
|
|
16
|
+
fs.closeSync(ttyFd);
|
|
17
|
+
} catch {
|
|
18
|
+
process.stdout.write(text); // fallback
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderScorecard(run) {
|
|
23
|
+
const W = Math.min(Math.max((process.stdout.columns || 88) - 4, 72), 120);
|
|
24
|
+
const won = run.status === 'won';
|
|
25
|
+
const flowMode = !run.budget;
|
|
26
|
+
|
|
27
|
+
const R = '\x1b[31m', G = '\x1b[32m', Y = '\x1b[33m', C = '\x1b[36m';
|
|
28
|
+
const M = '\x1b[35m', DIM = '\x1b[2m', RESET = '\x1b[0m', BOLD = '\x1b[1m';
|
|
29
|
+
const bc = won ? Y : R;
|
|
30
|
+
|
|
31
|
+
const tl = '╔', tr = '╗', bl = '╚', br = '╝';
|
|
32
|
+
const h = '═', v = '║';
|
|
33
|
+
const ml = '╠', mr = '╣';
|
|
34
|
+
|
|
35
|
+
function bar() { return bc + ml + h.repeat(W) + mr + RESET; }
|
|
36
|
+
function top() { return bc + tl + h.repeat(W) + tr + RESET; }
|
|
37
|
+
function bot() { return bc + bl + h.repeat(W) + br + RESET; }
|
|
38
|
+
function row(content) {
|
|
39
|
+
// Strip ANSI for length calculation
|
|
40
|
+
const plain = content.replace(/\x1b\[[0-9;]*m/g, '');
|
|
41
|
+
const pad = Math.max(0, W - plain.length - 2);
|
|
42
|
+
return bc + v + RESET + ' ' + content + ' '.repeat(pad) + ' ' + bc + v + RESET;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mc = getModelClass(run.model);
|
|
46
|
+
const tier = getTier(run.spent);
|
|
47
|
+
|
|
48
|
+
const fainted = run.fainted;
|
|
49
|
+
const sessions = run.sessionCount || 1;
|
|
50
|
+
const header = won
|
|
51
|
+
? `${BOLD}${Y}🏆 SESSION COMPLETE${RESET}`
|
|
52
|
+
: fainted
|
|
53
|
+
? `${BOLD}${Y}💤 FAINTED — Run Continues${RESET}`
|
|
54
|
+
: `${BOLD}${R}💀 BUDGET BUSTED${RESET}`;
|
|
55
|
+
|
|
56
|
+
const questStr = run.quest
|
|
57
|
+
? `${BOLD}${run.quest.slice(0, 60)}${RESET}`
|
|
58
|
+
: `${DIM}Flow Mode${RESET}`;
|
|
59
|
+
|
|
60
|
+
const spentBefore = run.spentBeforeThisSession || 0;
|
|
61
|
+
const spentThisSession = run.spent - spentBefore;
|
|
62
|
+
const multiSession = sessions > 1 && spentBefore > 0;
|
|
63
|
+
|
|
64
|
+
const spentStr = `${won ? G : R}$${run.spent.toFixed(4)}${RESET}` +
|
|
65
|
+
(multiSession ? ` ${DIM}(+$${spentThisSession.toFixed(4)} this session)${RESET}` : '');
|
|
66
|
+
|
|
67
|
+
let midRow = spentStr;
|
|
68
|
+
if (!flowMode) {
|
|
69
|
+
const pct = getBudgetPct(run.spent, run.budget);
|
|
70
|
+
const eff = getEfficiencyRating(run.spent, run.budget);
|
|
71
|
+
const effC = eff.color === 'magenta' ? M : eff.color === 'cyan' ? C : eff.color === 'green' ? G : eff.color === 'yellow' ? Y : R;
|
|
72
|
+
midRow += ` ${DIM}/${RESET}$${run.budget.toFixed(2)} ${pct}% ${effC}${eff.emoji} ${eff.label}${RESET}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const effortInfo = run.effort ? getEffortLevel(run.effort) : null;
|
|
76
|
+
const modelSuffix = [
|
|
77
|
+
run.effort && run.effort !== 'medium' && effortInfo ? effortInfo.label : null,
|
|
78
|
+
run.fastMode ? '⚡Fast' : null,
|
|
79
|
+
].filter(Boolean).join('·');
|
|
80
|
+
midRow += ` ${C}${mc.emoji} ${mc.name}${modelSuffix ? '·' + modelSuffix : ''}${RESET}`;
|
|
81
|
+
midRow += ` ${tier.emoji} ${tier.label}`;
|
|
82
|
+
if (multiSession) midRow += ` ${DIM}${sessions} sessions${RESET}`;
|
|
83
|
+
|
|
84
|
+
const achievements = run.achievements || [];
|
|
85
|
+
const achStr = achievements.map(a => `${a.emoji} ${a.key}`).join(' ');
|
|
86
|
+
|
|
87
|
+
const ti = run.thinkingInvocations || 0;
|
|
88
|
+
const thinkRow = ti > 0
|
|
89
|
+
? `${M}🔮 ${ti} ultrathink${ti > 1 ? ' invocations' : ' invocation'}${RESET}`
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
const lines = [
|
|
93
|
+
top(),
|
|
94
|
+
row(header),
|
|
95
|
+
row(questStr),
|
|
96
|
+
bar(),
|
|
97
|
+
row(midRow),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
if (thinkRow) {
|
|
101
|
+
lines.push(bar());
|
|
102
|
+
lines.push(row(thinkRow));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (achievements.length > 0) {
|
|
106
|
+
lines.push(bar());
|
|
107
|
+
lines.push(row(achStr));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lines.push(bar());
|
|
111
|
+
lines.push(row(`${DIM}tokengolf scorecard${RESET} · ${DIM}tokengolf start${RESET} · ${DIM}tokengolf stats${RESET}`));
|
|
112
|
+
lines.push(bot());
|
|
113
|
+
|
|
114
|
+
return lines.join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
let stdin = '';
|
|
119
|
+
try { stdin = fs.readFileSync('/dev/stdin', 'utf8'); } catch {}
|
|
120
|
+
|
|
121
|
+
let event = {};
|
|
122
|
+
try { event = JSON.parse(stdin); } catch {}
|
|
123
|
+
const reason = event.reason || 'other';
|
|
124
|
+
|
|
125
|
+
const run = getCurrentRun();
|
|
126
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
127
|
+
|
|
128
|
+
const result = autoDetectCost(run);
|
|
129
|
+
if (!result) process.exit(0); // no transcripts found, nothing to save
|
|
130
|
+
|
|
131
|
+
// reason 'other' = unexpected exit (usage limit hit = Fainted)
|
|
132
|
+
// clean exits: 'clear', 'logout', 'prompt_input_exit', 'bypass_permissions_disabled'
|
|
133
|
+
const cleanExits = ['clear', 'logout', 'prompt_input_exit', 'bypass_permissions_disabled'];
|
|
134
|
+
const fainted = !cleanExits.includes(reason) && reason !== 'other' ? false
|
|
135
|
+
: reason === 'other';
|
|
136
|
+
|
|
137
|
+
let status;
|
|
138
|
+
if (run.budget && result.spent > run.budget) status = 'died';
|
|
139
|
+
else if (fainted) status = 'resting'; // hit limit, run continues next session
|
|
140
|
+
else status = 'won';
|
|
141
|
+
|
|
142
|
+
const thinkingFields = {
|
|
143
|
+
thinkingInvocations: result.thinkingInvocations ?? 0,
|
|
144
|
+
thinkingTokens: result.thinkingTokens ?? 0,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// For resting runs: update state but don't clear — run continues next session
|
|
148
|
+
if (status === 'resting') {
|
|
149
|
+
const { setCurrentRun } = await import(path.join(__dir, '../src/lib/state.js'));
|
|
150
|
+
setCurrentRun({ ...run, spent: result.spent, fainted: true, ...thinkingFields });
|
|
151
|
+
const saved = { ...run, spent: result.spent, modelBreakdown: result.modelBreakdown, status, fainted: true, ...thinkingFields };
|
|
152
|
+
writeTTY('\n' + renderScorecard({ ...saved, achievements: [] }) + '\n\n');
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const saved = saveRun({
|
|
157
|
+
...run,
|
|
158
|
+
spent: result.spent,
|
|
159
|
+
modelBreakdown: result.modelBreakdown,
|
|
160
|
+
status,
|
|
161
|
+
endedAt: new Date().toISOString(),
|
|
162
|
+
...thinkingFields,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
clearCurrentRun();
|
|
166
|
+
|
|
167
|
+
writeTTY('\n' + renderScorecard(saved) + '\n\n');
|
|
168
|
+
} catch {
|
|
169
|
+
// silent fail
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
process.exit(0);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
const STATE_DIR = path.join(os.homedir(), '.tokengolf');
|
|
8
|
+
|
|
9
|
+
function detectEffort() {
|
|
10
|
+
const fromEnv = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
|
11
|
+
if (fromEnv) return fromEnv;
|
|
12
|
+
for (const p of [
|
|
13
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
14
|
+
path.join(process.env.PWD || process.cwd(), '.claude', 'settings.json'),
|
|
15
|
+
]) {
|
|
16
|
+
try { const s = JSON.parse(fs.readFileSync(p, 'utf8')); if (s.effortLevel) return s.effortLevel; } catch {}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function detectFastMode() {
|
|
22
|
+
try {
|
|
23
|
+
const s = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf8'));
|
|
24
|
+
return s.fastMode === true;
|
|
25
|
+
} catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const cwd = process.env.PWD || process.cwd();
|
|
30
|
+
|
|
31
|
+
let run = null;
|
|
32
|
+
try { run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { /* no run */ }
|
|
33
|
+
|
|
34
|
+
if (!run || run.status !== 'active') {
|
|
35
|
+
// Flow mode: auto-start a tracking run for this session
|
|
36
|
+
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
37
|
+
run = {
|
|
38
|
+
quest: null,
|
|
39
|
+
model: 'claude-sonnet-4-6',
|
|
40
|
+
budget: null,
|
|
41
|
+
effort: detectEffort(),
|
|
42
|
+
fastMode: detectFastMode(),
|
|
43
|
+
spent: 0,
|
|
44
|
+
status: 'active',
|
|
45
|
+
mode: 'flow',
|
|
46
|
+
floor: 1,
|
|
47
|
+
totalFloors: 5,
|
|
48
|
+
promptCount: 0,
|
|
49
|
+
totalToolCalls: 0,
|
|
50
|
+
toolCalls: {},
|
|
51
|
+
cwd,
|
|
52
|
+
sessionCount: 1,
|
|
53
|
+
compactionEvents: [],
|
|
54
|
+
startedAt: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(run, null, 2));
|
|
57
|
+
} else {
|
|
58
|
+
// Continuing an existing run — increment session count, snapshot spent for per-session tracking
|
|
59
|
+
run = {
|
|
60
|
+
...run,
|
|
61
|
+
sessionCount: (run.sessionCount || 1) + 1,
|
|
62
|
+
spentBeforeThisSession: run.spent || 0,
|
|
63
|
+
cwd: run.cwd || cwd,
|
|
64
|
+
effort: 'effort' in run ? run.effort : detectEffort(),
|
|
65
|
+
fastMode: 'fastMode' in run ? run.fastMode : detectFastMode(),
|
|
66
|
+
};
|
|
67
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(run, null, 2));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pct = run.budget ? run.spent / run.budget : 0;
|
|
71
|
+
const urgency = run.budget && pct >= 0.8 ? '⚠️ BUDGET CRITICAL — be concise. ' : '';
|
|
72
|
+
const questLine = run.quest ? `Quest: ${run.quest}` : 'Mode: Flow (auto-tracking)';
|
|
73
|
+
const budgetLine = run.budget
|
|
74
|
+
? `Budget: $${run.budget.toFixed(2)} | Spent: $${run.spent.toFixed(4)} (${Math.round(pct * 100)}%) | Remaining: $${(run.budget - run.spent).toFixed(4)}`
|
|
75
|
+
: '';
|
|
76
|
+
|
|
77
|
+
const effortStr = run.effort ? run.effort : 'default';
|
|
78
|
+
const fastStr = run.fastMode ? ' ⚡ Fast' : '';
|
|
79
|
+
const context = `## ⛳ TokenGolf Active
|
|
80
|
+
${urgency}Every token counts.
|
|
81
|
+
|
|
82
|
+
${questLine}
|
|
83
|
+
Model: ${run.model} | Effort: ${effortStr}${fastStr} | Floor: ${run.floor}/${run.totalFloors}
|
|
84
|
+
${budgetLine}
|
|
85
|
+
|
|
86
|
+
Efficiency tips:
|
|
87
|
+
- Use Read with start_line/end_line instead of reading whole files
|
|
88
|
+
- Be specific — avoid exploratory reads when you know the target
|
|
89
|
+
- Scope bash commands tightly`;
|
|
90
|
+
|
|
91
|
+
process.stdout.write(JSON.stringify({
|
|
92
|
+
hookSpecificOutput: {
|
|
93
|
+
hookEventName: 'SessionStart',
|
|
94
|
+
additionalContext: context,
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
97
|
+
} catch {
|
|
98
|
+
// silent fail
|
|
99
|
+
}
|
|
100
|
+
process.exit(0);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
|
|
8
|
+
let input = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
11
|
+
process.stdin.on('end', () => {
|
|
12
|
+
try {
|
|
13
|
+
const event = JSON.parse(input);
|
|
14
|
+
|
|
15
|
+
// Claude Code passes total_cost_usd in the Stop event
|
|
16
|
+
const cost = event.total_cost_usd ?? event.cost_usd ?? event.totalCostUsd ?? null;
|
|
17
|
+
if (cost == null) process.exit(0);
|
|
18
|
+
|
|
19
|
+
const run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
20
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
21
|
+
|
|
22
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...run, spent: cost }, null, 2));
|
|
23
|
+
} catch { /* no run or no cost data */ }
|
|
24
|
+
process.exit(0);
|
|
25
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
STATE_FILE="$HOME/.tokengolf/current-run.json"
|
|
3
|
+
SESSION_JSON=$(cat)
|
|
4
|
+
[ ! -f "$STATE_FILE" ] && exit 0
|
|
5
|
+
|
|
6
|
+
TG_SESSION_JSON="$SESSION_JSON" python3 - "$STATE_FILE" <<'PYEOF'
|
|
7
|
+
import sys, json, os
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
session = json.loads(os.environ.get('TG_SESSION_JSON') or '{}')
|
|
11
|
+
except: session = {}
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
with open(sys.argv[1]) as f: run = json.load(f)
|
|
15
|
+
except: sys.exit(0)
|
|
16
|
+
|
|
17
|
+
cost = (session.get('cost') or {}).get('total_cost_usd') or run.get('spent', 0)
|
|
18
|
+
ctx_pct = (session.get('context_window') or {}).get('used_percentage') or None
|
|
19
|
+
quest = (run.get('quest') or 'Flow')[:32]
|
|
20
|
+
budget = run.get('budget')
|
|
21
|
+
floor = f"{run.get('floor',1)}/{run.get('totalFloors',5)}"
|
|
22
|
+
m = run.get('model', '').lower()
|
|
23
|
+
if 'haiku' in m: model, model_emoji = 'Haiku', '🏹'
|
|
24
|
+
elif 'sonnet' in m: model, model_emoji = 'Sonnet', '⚔️'
|
|
25
|
+
elif 'opus' in m: model, model_emoji = 'Opus', '🧙'
|
|
26
|
+
else: model, model_emoji = '?', '?'
|
|
27
|
+
effort = run.get('effort')
|
|
28
|
+
fast = run.get('fastMode', False)
|
|
29
|
+
fainted = run.get('fainted', False)
|
|
30
|
+
|
|
31
|
+
label_parts = [f'{model_emoji} {model}']
|
|
32
|
+
if effort and effort != 'medium': label_parts.append(effort.capitalize())
|
|
33
|
+
if fast: label_parts.append('⚡Fast')
|
|
34
|
+
model_label = '·'.join(label_parts)
|
|
35
|
+
|
|
36
|
+
R, B, G, Y, M, C, DIM, RESET = '\033[31m','\033[34m','\033[32m','\033[33m','\033[35m','\033[36m','\033[2m','\033[0m'
|
|
37
|
+
BOLD = '\033[1m'
|
|
38
|
+
|
|
39
|
+
if cost < 0.10: tier_emoji = '💎'
|
|
40
|
+
elif cost < 0.30: tier_emoji = '🥇'
|
|
41
|
+
elif cost < 1.00: tier_emoji = '🥈'
|
|
42
|
+
elif cost < 3.00: tier_emoji = '🥉'
|
|
43
|
+
else: tier_emoji = '💸'
|
|
44
|
+
|
|
45
|
+
if budget:
|
|
46
|
+
pct = cost / budget * 100
|
|
47
|
+
if pct <= 25: rating, rc = 'LEGENDARY', M
|
|
48
|
+
elif pct <= 50: rating, rc = 'EFFICIENT', C
|
|
49
|
+
elif pct <= 75: rating, rc = 'SOLID', G
|
|
50
|
+
elif pct <= 100: rating, rc = 'CLOSE CALL', Y
|
|
51
|
+
else: rating, rc = 'BUSTED', R
|
|
52
|
+
cost_str = f"{tier_emoji} ${cost:.4f}/${budget:.2f} {pct:.0f}%"
|
|
53
|
+
rating_str = f"{rc}{rating}{RESET}"
|
|
54
|
+
else:
|
|
55
|
+
cost_str = f"{tier_emoji} ${cost:.4f}"
|
|
56
|
+
rating_str = None
|
|
57
|
+
|
|
58
|
+
sep = f" {DIM}|{RESET} "
|
|
59
|
+
ctx_str = None
|
|
60
|
+
if ctx_pct is not None:
|
|
61
|
+
if ctx_pct >= 90: ctx_str = f"{R}📦 {ctx_pct:.0f}%{RESET}"
|
|
62
|
+
elif ctx_pct >= 75: ctx_str = f"{Y}🎒 {ctx_pct:.0f}%{RESET}"
|
|
63
|
+
elif ctx_pct >= 50: ctx_str = f"{G}🪶 {ctx_pct:.0f}%{RESET}"
|
|
64
|
+
|
|
65
|
+
prefix = f"{BOLD}{C}{'💤' if fainted else '⛳'}{RESET}"
|
|
66
|
+
parts = [f"{prefix} {quest}", cost_str]
|
|
67
|
+
if rating_str: parts.append(rating_str)
|
|
68
|
+
if ctx_str: parts.append(ctx_str)
|
|
69
|
+
parts.append(f"{C}{model_label}{RESET}")
|
|
70
|
+
if budget: parts.append(f"Floor {floor}")
|
|
71
|
+
print('\n' + f'{DIM} ───────────────{RESET}' + '\n' + sep.join(parts) + '\n' + f'{DIM} ───────────────{RESET}')
|
|
72
|
+
PYEOF
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
10
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
11
|
+
|
|
12
|
+
const updated = { ...run, promptCount: (run.promptCount || 0) + 1 };
|
|
13
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(updated, null, 2));
|
|
14
|
+
|
|
15
|
+
const pct = updated.spent / updated.budget;
|
|
16
|
+
|
|
17
|
+
// Nudge at 50% — once (between 50-60%)
|
|
18
|
+
if (pct >= 0.5 && pct < 0.6) {
|
|
19
|
+
process.stdout.write(JSON.stringify({
|
|
20
|
+
hookSpecificOutput: {
|
|
21
|
+
hookEventName: 'UserPromptSubmit',
|
|
22
|
+
additionalContext: `[TokenGolf] Halfway point. $${updated.spent.toFixed(4)} of $${updated.budget.toFixed(2)} spent. Quest: "${updated.quest}" — stay focused.`,
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// silent fail
|
|
28
|
+
}
|
|
29
|
+
process.exit(0);
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tokengolf",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Gamify your Claude Code sessions. Flow mode tracks you. Roguelike mode trains you.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tokengolf": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "esbuild src/cli.js --bundle --platform=node --format=esm --loader:.js=jsx --packages=external --outfile=dist/cli.js && chmod +x dist/cli.js",
|
|
11
|
+
"prepare": "npm run build",
|
|
12
|
+
"dev": "node --import tsx/esm src/cli.js",
|
|
13
|
+
"postversion": "git push && git push --tags"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@inkjs/ui": "^2.0.0",
|
|
17
|
+
"commander": "^12.0.0",
|
|
18
|
+
"ink": "^5.0.0",
|
|
19
|
+
"react": "^18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"esbuild": "^0.25.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { getCurrentRun, clearCurrentRun, updateCurrentRun } from './lib/state.js';
|
|
7
|
+
import { saveRun, getLastRun, getStats } from './lib/store.js';
|
|
8
|
+
import { autoDetectCost } from './lib/cost.js';
|
|
9
|
+
import { StartRun } from './components/StartRun.js';
|
|
10
|
+
import { ActiveRun } from './components/ActiveRun.js';
|
|
11
|
+
import { ScoreCard } from './components/ScoreCard.js';
|
|
12
|
+
import { StatsView } from './components/StatsView.js';
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name('tokengolf')
|
|
16
|
+
.description('⛳ Gamify your Claude Code sessions')
|
|
17
|
+
.version('0.1.0');
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command('start')
|
|
21
|
+
.description('Declare a quest and start a new run')
|
|
22
|
+
.action(() => {
|
|
23
|
+
render(React.createElement(StartRun));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command('status')
|
|
28
|
+
.description('Show current run status')
|
|
29
|
+
.action(() => {
|
|
30
|
+
const run = getCurrentRun();
|
|
31
|
+
if (!run) {
|
|
32
|
+
console.log('No active run. Start one with: tokengolf start');
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
render(React.createElement(ActiveRun, { run }));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command('win')
|
|
40
|
+
.description('Mark current run as complete (won)')
|
|
41
|
+
.option('--spent <amount>', 'How much did you spend? (e.g. 0.18)')
|
|
42
|
+
.action((opts) => {
|
|
43
|
+
const run = getCurrentRun();
|
|
44
|
+
if (!run) {
|
|
45
|
+
console.log('No active run.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const detected = opts.spent ? null : autoDetectCost(run);
|
|
49
|
+
const spent = opts.spent ? parseFloat(opts.spent) : (detected?.spent ?? run.spent);
|
|
50
|
+
const completed = { ...run, spent, status: 'won', modelBreakdown: detected?.modelBreakdown ?? run.modelBreakdown ?? null, endedAt: new Date().toISOString() };
|
|
51
|
+
const saved = saveRun(completed);
|
|
52
|
+
clearCurrentRun();
|
|
53
|
+
render(React.createElement(ScoreCard, { run: saved }));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command('bust')
|
|
58
|
+
.description('Mark current run as budget busted (died)')
|
|
59
|
+
.option('--spent <amount>', 'How much did you spend? (e.g. 0.45)')
|
|
60
|
+
.action((opts) => {
|
|
61
|
+
const run = getCurrentRun();
|
|
62
|
+
if (!run) {
|
|
63
|
+
console.log('No active run.');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const detected = opts.spent ? null : autoDetectCost(run);
|
|
67
|
+
const spent = opts.spent ? parseFloat(opts.spent) : (detected?.spent ?? run.budget + 0.01);
|
|
68
|
+
const died = { ...run, spent, status: 'died', modelBreakdown: detected?.modelBreakdown ?? run.modelBreakdown ?? null, endedAt: new Date().toISOString() };
|
|
69
|
+
const saved = saveRun(died);
|
|
70
|
+
clearCurrentRun();
|
|
71
|
+
render(React.createElement(ScoreCard, { run: saved }));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
program
|
|
75
|
+
.command('floor')
|
|
76
|
+
.description('Advance to the next floor')
|
|
77
|
+
.action(() => {
|
|
78
|
+
const run = getCurrentRun();
|
|
79
|
+
if (!run) {
|
|
80
|
+
console.log('No active run.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const nextFloor = Math.min((run.floor || 1) + 1, run.totalFloors || 5);
|
|
84
|
+
updateCurrentRun({ floor: nextFloor });
|
|
85
|
+
console.log(`Floor ${nextFloor} / ${run.totalFloors}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
program
|
|
89
|
+
.command('scorecard')
|
|
90
|
+
.description('Show the last run scorecard')
|
|
91
|
+
.action(() => {
|
|
92
|
+
const run = getLastRun();
|
|
93
|
+
if (!run) {
|
|
94
|
+
console.log('No runs yet. Start one with: tokengolf start');
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
render(React.createElement(ScoreCard, { run }));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
program
|
|
101
|
+
.command('stats')
|
|
102
|
+
.description('Show career stats dashboard')
|
|
103
|
+
.action(() => {
|
|
104
|
+
render(React.createElement(StatsView, { stats: getStats() }));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
program
|
|
108
|
+
.command('install')
|
|
109
|
+
.description('Install Claude Code hooks into ~/.claude/settings.json')
|
|
110
|
+
.action(async () => {
|
|
111
|
+
const { installHooks } = await import('./lib/install.js');
|
|
112
|
+
installHooks();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
program.parse();
|