tokengolf 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokengolf",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Gamify your Claude Code sessions. Flow mode tracks you. Roguelike mode trains you.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,8 +8,15 @@
8
8
  },
9
9
  "scripts": {
10
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",
11
+ "prepare": "npm run build && husky",
12
12
  "dev": "node --import tsx/esm src/cli.js",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "lint": "eslint src/ hooks/",
16
+ "lint:fix": "eslint src/ hooks/ --fix",
17
+ "format": "prettier --write src/ hooks/",
18
+ "format:check": "prettier --check src/ hooks/",
19
+ "version": "git add CHANGELOG.md",
13
20
  "postversion": "git push && git push --tags"
14
21
  },
15
22
  "dependencies": {
@@ -19,7 +26,13 @@
19
26
  "react": "^18.0.0"
20
27
  },
21
28
  "devDependencies": {
22
- "esbuild": "^0.25.0"
29
+ "@eslint/js": "^10.0.1",
30
+ "esbuild": "^0.25.0",
31
+ "eslint": "^10.0.3",
32
+ "eslint-config-prettier": "^10.1.8",
33
+ "husky": "^9.1.7",
34
+ "prettier": "^3.8.1",
35
+ "vitest": "^4.0.18"
23
36
  },
24
37
  "engines": {
25
38
  "node": ">=18.0.0"
package/src/cli.js CHANGED
@@ -11,10 +11,7 @@ import { ActiveRun } from './components/ActiveRun.js';
11
11
  import { ScoreCard } from './components/ScoreCard.js';
12
12
  import { StatsView } from './components/StatsView.js';
13
13
 
14
- program
15
- .name('tokengolf')
16
- .description('⛳ Gamify your Claude Code sessions')
17
- .version('0.1.0');
14
+ program.name('tokengolf').description('⛳ Gamify your Claude Code sessions').version('0.1.0');
18
15
 
19
16
  program
20
17
  .command('start')
@@ -47,7 +44,13 @@ program
47
44
  }
48
45
  const detected = opts.spent ? null : autoDetectCost(run);
49
46
  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() };
47
+ const completed = {
48
+ ...run,
49
+ spent,
50
+ status: 'won',
51
+ modelBreakdown: detected?.modelBreakdown ?? run.modelBreakdown ?? null,
52
+ endedAt: new Date().toISOString(),
53
+ };
51
54
  const saved = saveRun(completed);
52
55
  clearCurrentRun();
53
56
  render(React.createElement(ScoreCard, { run: saved }));
@@ -65,7 +68,13 @@ program
65
68
  }
66
69
  const detected = opts.spent ? null : autoDetectCost(run);
67
70
  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() };
71
+ const died = {
72
+ ...run,
73
+ spent,
74
+ status: 'died',
75
+ modelBreakdown: detected?.modelBreakdown ?? run.modelBreakdown ?? null,
76
+ endedAt: new Date().toISOString(),
77
+ };
69
78
  const saved = saveRun(died);
70
79
  clearCurrentRun();
71
80
  render(React.createElement(ScoreCard, { run: saved }));
@@ -104,6 +113,14 @@ program
104
113
  render(React.createElement(StatsView, { stats: getStats() }));
105
114
  });
106
115
 
116
+ program
117
+ .command('demo')
118
+ .description('Show HUD examples for all game states (great for screenshots)')
119
+ .action(async () => {
120
+ const { runDemo } = await import('./lib/demo.js');
121
+ runDemo();
122
+ });
123
+
107
124
  program
108
125
  .command('install')
109
126
  .description('Install Claude Code hooks into ~/.claude/settings.json')
@@ -2,7 +2,14 @@ import React, { useState, useEffect } from 'react';
2
2
  import { Box, Text, useApp, useInput } from 'ink';
3
3
  import { ProgressBar } from '@inkjs/ui';
4
4
  import { getCurrentRun } from '../lib/state.js';
5
- import { getModelClass, getEfficiencyRating, getBudgetPct, formatCost, formatElapsed, FLOORS } from '../lib/score.js';
5
+ import {
6
+ getModelClass,
7
+ getEfficiencyRating,
8
+ getBudgetPct,
9
+ formatCost,
10
+ formatElapsed,
11
+ FLOORS,
12
+ } from '../lib/score.js';
6
13
 
7
14
  export function ActiveRun({ run: initialRun }) {
8
15
  const { exit } = useApp();
@@ -13,41 +20,75 @@ export function ActiveRun({ run: initialRun }) {
13
20
  const interval = setInterval(() => {
14
21
  const latest = getCurrentRun();
15
22
  if (latest) setRun(latest);
16
- setTick(t => t + 1);
23
+ setTick((t) => t + 1);
17
24
  }, 2000);
18
25
  return () => clearInterval(interval);
19
26
  }, []);
20
27
 
21
- useInput((input) => { if (input === 'q') exit(); });
28
+ useInput((input) => {
29
+ if (input === 'q') exit();
30
+ });
22
31
 
32
+ const flowMode = !run.budget;
23
33
  const mc = getModelClass(run.model);
24
- const pct = getBudgetPct(run.spent, run.budget);
25
- const efficiency = getEfficiencyRating(run.spent, run.budget);
26
- const barColor = pct >= 80 ? 'red' : pct >= 50 ? 'yellow' : 'green';
34
+ const pct = flowMode ? null : getBudgetPct(run.spent, run.budget);
35
+ const efficiency = flowMode ? null : getEfficiencyRating(run.spent, run.budget);
36
+ const barColor = !pct ? 'green' : pct >= 80 ? 'red' : pct >= 50 ? 'yellow' : 'green';
27
37
 
28
38
  return (
29
39
  <Box flexDirection="column" gap={1} paddingX={1} paddingY={1}>
30
40
  <Box gap={2}>
31
- <Text bold color="yellow">⛳ TokenGolf</Text>
41
+ <Text bold color="yellow">
42
+ ⛳ TokenGolf
43
+ </Text>
32
44
  <Text color="gray">Active Run</Text>
33
- <Text color="gray" dimColor>{formatElapsed(run.startedAt)}</Text>
45
+ <Text color="gray" dimColor>
46
+ {formatElapsed(run.startedAt)}
47
+ </Text>
34
48
  </Box>
35
49
 
36
- <Box borderStyle="round" borderColor="yellow" paddingX={1} paddingY={1} flexDirection="column" gap={1}>
37
- <Text bold color="white">{run.quest}</Text>
50
+ <Box
51
+ borderStyle="round"
52
+ borderColor="yellow"
53
+ paddingX={1}
54
+ paddingY={1}
55
+ flexDirection="column"
56
+ gap={1}
57
+ >
58
+ <Text bold color="white">
59
+ {run.quest}
60
+ </Text>
38
61
 
39
62
  <Box gap={3}>
40
- <Text>{mc.emoji} <Text color="cyan">{mc.name}</Text></Text>
41
- <Text color="gray">Budget <Text color="green">${run.budget.toFixed(2)}</Text></Text>
42
- <Text color="gray">Spent <Text color={barColor}>{formatCost(run.spent)}</Text></Text>
43
- <Text color={efficiency.color}>{efficiency.emoji} {efficiency.label}</Text>
63
+ <Text>
64
+ {mc.emoji} <Text color="cyan">{mc.name}</Text>
65
+ </Text>
66
+ {flowMode ? (
67
+ <Text color="gray">Flow Mode</Text>
68
+ ) : (
69
+ <Text color="gray">
70
+ Budget <Text color="green">${run.budget.toFixed(2)}</Text>
71
+ </Text>
72
+ )}
73
+ <Text color="gray">
74
+ Spent <Text color={barColor}>{formatCost(run.spent)}</Text>
75
+ </Text>
76
+ {!flowMode && (
77
+ <Text color={efficiency.color}>
78
+ {efficiency.emoji} {efficiency.label}
79
+ </Text>
80
+ )}
44
81
  </Box>
45
82
 
46
- <Box gap={1} alignItems="center">
47
- <Text color="gray">💰 </Text>
48
- <Box width={24}><ProgressBar value={Math.min(pct, 100)} /></Box>
49
- <Text color={barColor}> {pct}%</Text>
50
- </Box>
83
+ {!flowMode && (
84
+ <Box gap={1} alignItems="center">
85
+ <Text color="gray">💰 </Text>
86
+ <Box width={24}>
87
+ <ProgressBar value={Math.min(pct, 100)} />
88
+ </Box>
89
+ <Text color={barColor}> {pct}%</Text>
90
+ </Box>
91
+ )}
51
92
 
52
93
  <Box flexDirection="column" gap={0} marginTop={1}>
53
94
  {FLOORS.map((floor, i) => {
@@ -59,7 +100,10 @@ export function ActiveRun({ run: initialRun }) {
59
100
  <Text color={done ? 'green' : active ? 'yellow' : 'gray'}>
60
101
  {done ? '✓' : active ? '▶' : '○'}
61
102
  </Text>
62
- <Text color={done ? 'green' : active ? 'white' : 'gray'} dimColor={!done && !active}>
103
+ <Text
104
+ color={done ? 'green' : active ? 'white' : 'gray'}
105
+ dimColor={!done && !active}
106
+ >
63
107
  Floor {n}: {floor}
64
108
  </Text>
65
109
  </Box>
@@ -68,18 +112,26 @@ export function ActiveRun({ run: initialRun }) {
68
112
  </Box>
69
113
 
70
114
  <Box gap={3} marginTop={1}>
71
- <Text color="gray">Prompts <Text color="white">{run.promptCount || 0}</Text></Text>
72
- <Text color="gray">Tools <Text color="white">{run.totalToolCalls || 0}</Text></Text>
115
+ <Text color="gray">
116
+ Prompts <Text color="white">{run.promptCount || 0}</Text>
117
+ </Text>
118
+ <Text color="gray">
119
+ Tools <Text color="white">{run.totalToolCalls || 0}</Text>
120
+ </Text>
73
121
  </Box>
74
122
 
75
123
  {pct >= 80 && pct < 100 && (
76
124
  <Box borderStyle="single" borderColor="red" paddingX={1}>
77
- <Text color="red" bold>⚠️ BUDGET WARNING — {formatCost(run.budget - run.spent)} left</Text>
125
+ <Text color="red" bold>
126
+ ⚠️ BUDGET WARNING — {formatCost(run.budget - run.spent)} left
127
+ </Text>
78
128
  </Box>
79
129
  )}
80
130
  </Box>
81
131
 
82
- <Text color="gray" dimColor>tokengolf win [--spent 0.18] · tokengolf bust · q to close</Text>
132
+ <Text color="gray" dimColor>
133
+ tokengolf win [--spent 0.18] · tokengolf bust · q to close
134
+ </Text>
83
135
  </Box>
84
136
  );
85
137
  }
@@ -1,12 +1,24 @@
1
1
  import React, { useEffect } from 'react';
2
2
  import { Box, Text, useApp, useInput } from 'ink';
3
- import { getTier, getModelClass, getEffortLevel, getEfficiencyRating, getBudgetPct, formatCost, getHaikuPct } from '../lib/score.js';
3
+ import {
4
+ getTier,
5
+ getModelClass,
6
+ getEffortLevel,
7
+ getEfficiencyRating,
8
+ getBudgetPct,
9
+ formatCost,
10
+ getHaikuPct,
11
+ getOpusPct,
12
+ MODEL_CLASSES,
13
+ } from '../lib/score.js';
4
14
 
5
15
  export function ScoreCard({ run }) {
6
16
  const { exit } = useApp();
7
17
  const won = run.status === 'won';
8
18
 
9
- useInput((input) => { if (input === 'q') exit(); });
19
+ useInput((input) => {
20
+ if (input === 'q') exit();
21
+ });
10
22
 
11
23
  useEffect(() => {
12
24
  const t = setTimeout(() => exit(), 60000);
@@ -19,74 +31,116 @@ export function ScoreCard({ run }) {
19
31
  const efficiency = flowMode ? null : getEfficiencyRating(run.spent, run.budget);
20
32
  const pct = flowMode ? null : getBudgetPct(run.spent, run.budget);
21
33
  const haikuPct = getHaikuPct(run.modelBreakdown, run.spent);
34
+ const opusPct = getOpusPct(run.modelBreakdown, run.spent);
22
35
 
23
36
  return (
24
37
  <Box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
25
-
26
38
  {/* Big status header */}
27
- <Box borderStyle="double" borderColor={won ? 'yellow' : 'red'} paddingX={2} paddingY={1} flexDirection="column" gap={1}>
28
- <Text bold color={won ? 'yellow' : 'red'} >
39
+ <Box
40
+ borderStyle="double"
41
+ borderColor={won ? 'yellow' : 'red'}
42
+ paddingX={2}
43
+ paddingY={1}
44
+ flexDirection="column"
45
+ gap={1}
46
+ >
47
+ <Text bold color={won ? 'yellow' : 'red'}>
29
48
  {won ? '🏆 SESSION COMPLETE' : '💀 BUDGET BUSTED'}
30
49
  </Text>
31
50
 
32
- <Text color="white" bold>{run.quest ?? <Text color="gray">Flow Mode</Text>}</Text>
51
+ <Text color="white" bold>
52
+ {run.quest ?? <Text color="gray">Flow Mode</Text>}
53
+ </Text>
33
54
 
34
55
  {/* Score row */}
35
56
  <Box gap={4} flexWrap="wrap" marginTop={1}>
36
57
  <Box flexDirection="column">
37
- <Text color="gray" dimColor>SPENT</Text>
38
- <Text bold color={won ? 'green' : 'red'}>{formatCost(run.spent)}</Text>
58
+ <Text color="gray" dimColor>
59
+ SPENT
60
+ </Text>
61
+ <Text bold color={won ? 'green' : 'red'}>
62
+ {formatCost(run.spent)}
63
+ </Text>
39
64
  </Box>
40
65
  {!flowMode && (
41
66
  <>
42
67
  <Box flexDirection="column">
43
- <Text color="gray" dimColor>BUDGET</Text>
68
+ <Text color="gray" dimColor>
69
+ BUDGET
70
+ </Text>
44
71
  <Text color="white">${run.budget.toFixed(2)}</Text>
45
72
  </Box>
46
73
  <Box flexDirection="column">
47
- <Text color="gray" dimColor>USED</Text>
74
+ <Text color="gray" dimColor>
75
+ USED
76
+ </Text>
48
77
  <Text color={pct > 100 ? 'red' : pct > 80 ? 'yellow' : 'green'}>{pct}%</Text>
49
78
  </Box>
50
79
  </>
51
80
  )}
52
81
  <Box flexDirection="column">
53
- <Text color="gray" dimColor>MODEL</Text>
54
- <Text color="cyan">{mc.emoji} {mc.name}{[
55
- run.effort && run.effort !== 'medium' ? getEffortLevel(run.effort)?.label : null,
56
- run.fastMode ? 'Fast' : null,
57
- ].filter(Boolean).map(s => `·${s}`).join('')}</Text>
82
+ <Text color="gray" dimColor>
83
+ MODEL
84
+ </Text>
85
+ <Text color="cyan">
86
+ {mc.emoji} {mc.name}
87
+ {[
88
+ run.effort && run.effort !== 'medium' ? getEffortLevel(run.effort)?.label : null,
89
+ run.fastMode ? 'Fast' : null,
90
+ ]
91
+ .filter(Boolean)
92
+ .map((s) => `·${s}`)
93
+ .join('')}
94
+ </Text>
58
95
  </Box>
59
96
  {run.effort && (
60
97
  <Box flexDirection="column">
61
- <Text color="gray" dimColor>EFFORT</Text>
62
- <Text color={getEffortLevel(run.effort)?.color}>{getEffortLevel(run.effort)?.emoji} {getEffortLevel(run.effort)?.label}</Text>
98
+ <Text color="gray" dimColor>
99
+ EFFORT
100
+ </Text>
101
+ <Text color={getEffortLevel(run.effort)?.color}>
102
+ {getEffortLevel(run.effort)?.emoji} {getEffortLevel(run.effort)?.label}
103
+ </Text>
63
104
  </Box>
64
105
  )}
65
106
  {run.fastMode && (
66
107
  <Box flexDirection="column">
67
- <Text color="gray" dimColor>MODE</Text>
108
+ <Text color="gray" dimColor>
109
+ MODE
110
+ </Text>
68
111
  <Text color="yellow">↯ Fast</Text>
69
112
  </Box>
70
113
  )}
71
114
  <Box flexDirection="column">
72
- <Text color="gray" dimColor>TIER</Text>
73
- <Text color={tier.color}>{tier.emoji} {tier.label}</Text>
115
+ <Text color="gray" dimColor>
116
+ TIER
117
+ </Text>
118
+ <Text color={tier.color}>
119
+ {tier.emoji} {tier.label}
120
+ </Text>
74
121
  </Box>
75
122
  </Box>
76
123
 
77
124
  {/* Efficiency (roguelike mode only) */}
78
125
  {efficiency && (
79
126
  <Box gap={2}>
80
- <Text bold color={efficiency.color}>{efficiency.emoji} {efficiency.label}</Text>
127
+ <Text bold color={efficiency.color}>
128
+ {efficiency.emoji} {efficiency.label}
129
+ </Text>
81
130
  </Box>
82
131
  )}
83
132
 
84
133
  {/* Achievements */}
85
134
  {run.achievements?.length > 0 && (
86
135
  <Box flexDirection="column" gap={0} marginTop={1}>
87
- <Text color="gray" dimColor>Achievements unlocked:</Text>
136
+ <Text color="gray" dimColor>
137
+ Achievements unlocked:
138
+ </Text>
88
139
  {run.achievements.map((a, i) => (
89
- <Text key={i} color="yellow"> {a.emoji} {a.label}</Text>
140
+ <Text key={i} color="yellow">
141
+ {' '}
142
+ {a.emoji} {a.label}
143
+ </Text>
90
144
  ))}
91
145
  </Box>
92
146
  )}
@@ -95,7 +149,9 @@ export function ScoreCard({ run }) {
95
149
  {run.thinkingInvocations > 0 && (
96
150
  <Box flexDirection="column" gap={0} marginTop={1}>
97
151
  <Box gap={3} alignItems="center">
98
- <Text color="gray" dimColor>Extended thinking:</Text>
152
+ <Text color="gray" dimColor>
153
+ Extended thinking:
154
+ </Text>
99
155
  <Text color="magenta">🔮 {run.thinkingInvocations}× invoked</Text>
100
156
  </Box>
101
157
  </Box>
@@ -105,19 +161,34 @@ export function ScoreCard({ run }) {
105
161
  {run.modelBreakdown && Object.keys(run.modelBreakdown).length > 0 && (
106
162
  <Box flexDirection="column" gap={0} marginTop={1}>
107
163
  <Box gap={2} alignItems="center">
108
- <Text color="gray" dimColor>Model usage:</Text>
164
+ <Text color="gray" dimColor>
165
+ Model usage:
166
+ </Text>
109
167
  {haikuPct !== null && (
110
168
  <Text color={haikuPct >= 75 ? 'magenta' : haikuPct >= 50 ? 'cyan' : 'yellow'}>
111
169
  🏹 {haikuPct}% Haiku
112
170
  </Text>
113
171
  )}
172
+ {mc === MODEL_CLASSES.opusplan && opusPct !== null && (
173
+ <Text color="yellow">⚜️ {opusPct}% Opus (planning)</Text>
174
+ )}
114
175
  </Box>
115
176
  <Box gap={3} flexWrap="wrap">
116
177
  {Object.entries(run.modelBreakdown).map(([model, cost]) => {
117
- const short = model.includes('haiku') ? 'Haiku' : model.includes('sonnet') ? 'Sonnet' : 'Opus';
178
+ const m = model.toLowerCase();
179
+ const short = m.includes('haiku')
180
+ ? 'Haiku'
181
+ : m.includes('sonnet')
182
+ ? 'Sonnet'
183
+ : m.includes('opusplan') || m.includes('paladin')
184
+ ? 'Paladin'
185
+ : 'Opus';
118
186
  const pctOfTotal = Math.round((cost / run.spent) * 100);
119
187
  return (
120
- <Text key={model} color="gray">{short} <Text color="white">{pctOfTotal}%</Text> <Text dimColor>{formatCost(cost)}</Text></Text>
188
+ <Text key={model} color="gray">
189
+ {short} <Text color="white">{pctOfTotal}%</Text>{' '}
190
+ <Text dimColor>{formatCost(cost)}</Text>
191
+ </Text>
121
192
  );
122
193
  })}
123
194
  </Box>
@@ -127,10 +198,14 @@ export function ScoreCard({ run }) {
127
198
  {/* Tool breakdown */}
128
199
  {run.toolCalls && Object.keys(run.toolCalls).length > 0 && (
129
200
  <Box flexDirection="column" gap={0} marginTop={1}>
130
- <Text color="gray" dimColor>Tool calls:</Text>
201
+ <Text color="gray" dimColor>
202
+ Tool calls:
203
+ </Text>
131
204
  <Box gap={2} flexWrap="wrap">
132
205
  {Object.entries(run.toolCalls).map(([tool, count]) => (
133
- <Text key={tool} color="gray"><Text color="white">{tool}</Text> ×{count}</Text>
206
+ <Text key={tool} color="gray">
207
+ <Text color="white">{tool}</Text> ×{count}
208
+ </Text>
134
209
  ))}
135
210
  </Box>
136
211
  </Box>
@@ -138,19 +213,39 @@ export function ScoreCard({ run }) {
138
213
 
139
214
  {/* Death tip */}
140
215
  {!won && run.budget && (
141
- <Box borderStyle="single" borderColor="red" paddingX={1} marginTop={1} flexDirection="column">
142
- <Text color="red" bold>Cause of death: Budget exceeded by {formatCost(run.spent - run.budget)}</Text>
143
- <Text color="gray" dimColor>Tip: Use Read with line ranges instead of full file reads.</Text>
216
+ <Box
217
+ borderStyle="single"
218
+ borderColor="red"
219
+ paddingX={1}
220
+ marginTop={1}
221
+ flexDirection="column"
222
+ >
223
+ <Text color="red" bold>
224
+ Cause of death: Budget exceeded by {formatCost(run.spent - run.budget)}
225
+ </Text>
226
+ <Text color="gray" dimColor>
227
+ Tip: Use Read with line ranges instead of full file reads.
228
+ </Text>
144
229
  </Box>
145
230
  )}
146
231
  </Box>
147
232
 
148
233
  <Box gap={2}>
149
- <Text color="gray" dimColor>tokengolf start — run again</Text>
150
- <Text color="gray" dimColor>·</Text>
151
- <Text color="gray" dimColor>tokengolf stats — career stats</Text>
152
- <Text color="gray" dimColor>·</Text>
153
- <Text color="gray" dimColor>q to exit</Text>
234
+ <Text color="gray" dimColor>
235
+ tokengolf start — run again
236
+ </Text>
237
+ <Text color="gray" dimColor>
238
+ ·
239
+ </Text>
240
+ <Text color="gray" dimColor>
241
+ tokengolf stats — career stats
242
+ </Text>
243
+ <Text color="gray" dimColor>
244
+ ·
245
+ </Text>
246
+ <Text color="gray" dimColor>
247
+ q to exit
248
+ </Text>
154
249
  </Box>
155
250
  </Box>
156
251
  );