tokburn 0.1.0 → 0.2.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/cli.js +7 -2
- package/init-ui.mjs +466 -0
- package/init.js +67 -1
- package/package.json +7 -2
- package/statusline.js +113 -109
package/cli.js
CHANGED
|
@@ -120,8 +120,13 @@ program
|
|
|
120
120
|
.command('init')
|
|
121
121
|
.description('Interactive setup wizard for Claude Code')
|
|
122
122
|
.action(async () => {
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
try {
|
|
124
|
+
await import('./init-ui.mjs');
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Fallback to readline wizard if Ink fails (e.g. non-TTY, missing deps)
|
|
127
|
+
const { runInit } = require('./init');
|
|
128
|
+
await runInit();
|
|
129
|
+
}
|
|
125
130
|
});
|
|
126
131
|
|
|
127
132
|
program
|
package/init-ui.mjs
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tokburn -- init-ui.mjs
|
|
3
|
+
* Ink-based setup wizard for `tokburn init`.
|
|
4
|
+
* ESM module using Ink 6.x, @inkjs/ui v2, React 19.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
8
|
+
import { render, Box, Text, Newline, useApp } from 'ink';
|
|
9
|
+
import { Select, Spinner, ProgressBar } from '@inkjs/ui';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { detectEnvironment, configurePlan, configureProxy, configureShell, configureStatusLine, PLANS } = require('./init.js');
|
|
15
|
+
const { getConfig } = require('./config.js');
|
|
16
|
+
const { PRESETS, MODULE_LIST } = require('./statusline.js');
|
|
17
|
+
|
|
18
|
+
const pkg = require('./package.json');
|
|
19
|
+
|
|
20
|
+
// ── ASCII Logo ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const LOGO = ` _ _
|
|
23
|
+
| |_ ___ | | __ ___ _ _ _ __ _ __
|
|
24
|
+
| __/ _ \\| |/ /| _ | | | | '__| '_ \\
|
|
25
|
+
| || (_) | < | _ | |_| | | | | | |
|
|
26
|
+
\\__\\___/|_|\\_\\|___|\\__,_|_| |_| |_|`;
|
|
27
|
+
|
|
28
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const PLAN_LABELS = {
|
|
31
|
+
pro: 'Pro (~500K/5hr)',
|
|
32
|
+
max: 'Max (~2M/5hr)',
|
|
33
|
+
api: 'API only',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function dots(label, value, width = 40) {
|
|
37
|
+
const used = label.length + value.length + 2;
|
|
38
|
+
const count = Math.max(2, width - used);
|
|
39
|
+
return chalk.dim('.'.repeat(count));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function completedLine(label, value) {
|
|
43
|
+
return ` ${chalk.green('\u2713')} ${label} ${dots(label, value)} ${chalk.bold(value)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function previewForPreset(presetKey) {
|
|
47
|
+
if (presetKey === 'skip') return '(no status line)';
|
|
48
|
+
const modules = PRESETS[presetKey];
|
|
49
|
+
if (!modules) return '';
|
|
50
|
+
const lineOne = [];
|
|
51
|
+
const extra = [];
|
|
52
|
+
for (const key of modules) {
|
|
53
|
+
const info = MODULE_LIST.find(m => m.key === key);
|
|
54
|
+
if (!info) continue;
|
|
55
|
+
if (key === 'current_limit' || key === 'weekly_limit') {
|
|
56
|
+
extra.push(info.example);
|
|
57
|
+
} else {
|
|
58
|
+
lineOne.push(info.example);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let out = '';
|
|
62
|
+
if (lineOne.length > 0) out += lineOne.join(' | ');
|
|
63
|
+
for (const e of extra) out += '\n' + e;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
68
|
+
|
|
69
|
+
// ── Phase constants ─────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const PHASE_WELCOME = 'welcome';
|
|
72
|
+
const PHASE_PLAN = 'plan';
|
|
73
|
+
const PHASE_PROXY = 'proxy';
|
|
74
|
+
const PHASE_SHELL = 'shell';
|
|
75
|
+
const PHASE_STATUSLINE = 'statusline';
|
|
76
|
+
const PHASE_PROCESSING = 'processing';
|
|
77
|
+
const PHASE_DONE = 'done';
|
|
78
|
+
|
|
79
|
+
// ── Main App ────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function App() {
|
|
82
|
+
const { exit } = useApp();
|
|
83
|
+
const [phase, setPhase] = useState(PHASE_WELCOME);
|
|
84
|
+
const [env, setEnv] = useState(null);
|
|
85
|
+
|
|
86
|
+
// User choices
|
|
87
|
+
const [plan, setPlan] = useState(null);
|
|
88
|
+
const [wantProxy, setWantProxy] = useState(null);
|
|
89
|
+
const [wantShell, setWantShell] = useState(null);
|
|
90
|
+
const [statusPreset, setStatusPreset] = useState(null);
|
|
91
|
+
|
|
92
|
+
// Processing state
|
|
93
|
+
const [taskIndex, setTaskIndex] = useState(-1);
|
|
94
|
+
const [taskResults, setTaskResults] = useState({});
|
|
95
|
+
const [progress, setProgress] = useState(0);
|
|
96
|
+
|
|
97
|
+
// Detect environment on mount
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
try {
|
|
100
|
+
const detected = detectEnvironment();
|
|
101
|
+
setEnv(detected);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
setEnv({ home: '', shell: 'unknown', rcFile: '', rcPath: '', claudeDir: '', claudeSettings: '', hasClaudeCode: false });
|
|
104
|
+
}
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
// Auto-advance from welcome after a short pause
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (phase === PHASE_WELCOME && env) {
|
|
110
|
+
const t = setTimeout(() => setPhase(PHASE_PLAN), 100);
|
|
111
|
+
return () => clearTimeout(t);
|
|
112
|
+
}
|
|
113
|
+
}, [phase, env]);
|
|
114
|
+
|
|
115
|
+
// Run processing tasks
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (phase !== PHASE_PROCESSING) return;
|
|
118
|
+
let cancelled = false;
|
|
119
|
+
|
|
120
|
+
async function runTasks() {
|
|
121
|
+
const tasks = buildTaskList();
|
|
122
|
+
const results = {};
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
125
|
+
if (cancelled) return;
|
|
126
|
+
setTaskIndex(i);
|
|
127
|
+
setProgress(Math.round(((i) / tasks.length) * 100));
|
|
128
|
+
await sleep(200);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const result = await tasks[i].run();
|
|
132
|
+
results[tasks[i].key] = { success: true, ...(result || {}) };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
results[tasks[i].key] = { success: false, error: e.message };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (cancelled) return;
|
|
139
|
+
setTaskResults(results);
|
|
140
|
+
setProgress(100);
|
|
141
|
+
setTaskIndex(tasks.length);
|
|
142
|
+
await sleep(300);
|
|
143
|
+
setPhase(PHASE_DONE);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
runTasks();
|
|
147
|
+
return () => { cancelled = true; };
|
|
148
|
+
}, [phase]);
|
|
149
|
+
|
|
150
|
+
// Exit after done phase renders
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (phase === PHASE_DONE) {
|
|
153
|
+
const t = setTimeout(() => exit(), 500);
|
|
154
|
+
return () => clearTimeout(t);
|
|
155
|
+
}
|
|
156
|
+
}, [phase, exit]);
|
|
157
|
+
|
|
158
|
+
// ── Task builders ───────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function buildTaskList() {
|
|
161
|
+
const tasks = [];
|
|
162
|
+
|
|
163
|
+
tasks.push({
|
|
164
|
+
key: 'plan',
|
|
165
|
+
label: 'Plan configured',
|
|
166
|
+
run: () => {
|
|
167
|
+
configurePlan(plan);
|
|
168
|
+
return {};
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (wantProxy) {
|
|
173
|
+
tasks.push({
|
|
174
|
+
key: 'proxy',
|
|
175
|
+
label: 'Proxy started',
|
|
176
|
+
run: () => {
|
|
177
|
+
const result = configureProxy();
|
|
178
|
+
return { pid: result.pid, message: result.message };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (wantShell && env && env.hasClaudeCode) {
|
|
184
|
+
tasks.push({
|
|
185
|
+
key: 'shell',
|
|
186
|
+
label: 'Shell configured',
|
|
187
|
+
run: () => {
|
|
188
|
+
const config = getConfig();
|
|
189
|
+
const result = configureShell(env.rcPath, config.port);
|
|
190
|
+
return { added: result.added, reason: result.reason };
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (statusPreset && statusPreset !== 'skip' && env && env.hasClaudeCode) {
|
|
196
|
+
tasks.push({
|
|
197
|
+
key: 'statusline',
|
|
198
|
+
label: 'Status line configured',
|
|
199
|
+
run: () => {
|
|
200
|
+
const modules = PRESETS[statusPreset];
|
|
201
|
+
configureStatusLine(modules);
|
|
202
|
+
return { count: modules.length };
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return tasks;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Handlers ────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
const handlePlanSelect = useCallback((value) => {
|
|
213
|
+
setPlan(value);
|
|
214
|
+
setPhase(PHASE_PROXY);
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
const handleProxySelect = useCallback((value) => {
|
|
218
|
+
setWantProxy(value === 'yes');
|
|
219
|
+
if (env && env.hasClaudeCode) {
|
|
220
|
+
setPhase(PHASE_SHELL);
|
|
221
|
+
} else {
|
|
222
|
+
// Skip shell and statusline if no Claude Code
|
|
223
|
+
setWantShell(false);
|
|
224
|
+
setStatusPreset('skip');
|
|
225
|
+
setPhase(PHASE_PROCESSING);
|
|
226
|
+
}
|
|
227
|
+
}, [env]);
|
|
228
|
+
|
|
229
|
+
const handleShellSelect = useCallback((value) => {
|
|
230
|
+
setWantShell(value === 'yes');
|
|
231
|
+
if (env && env.hasClaudeCode) {
|
|
232
|
+
setPhase(PHASE_STATUSLINE);
|
|
233
|
+
} else {
|
|
234
|
+
setStatusPreset('skip');
|
|
235
|
+
setPhase(PHASE_PROCESSING);
|
|
236
|
+
}
|
|
237
|
+
}, [env]);
|
|
238
|
+
|
|
239
|
+
const handleStatusLineSelect = useCallback((value) => {
|
|
240
|
+
setStatusPreset(value);
|
|
241
|
+
setPhase(PHASE_PROCESSING);
|
|
242
|
+
}, []);
|
|
243
|
+
|
|
244
|
+
// ── Render ──────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
if (!env) {
|
|
247
|
+
return React.createElement(Box, { paddingX: 2 },
|
|
248
|
+
React.createElement(Spinner, { label: 'Detecting environment...' })
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return React.createElement(Box, { flexDirection: 'column', paddingX: 1 },
|
|
253
|
+
// Logo + header (always shown)
|
|
254
|
+
React.createElement(Box, { flexDirection: 'column' },
|
|
255
|
+
React.createElement(Text, { color: 'yellow' }, LOGO),
|
|
256
|
+
React.createElement(Text, { dimColor: true }, ` token tracking for Claude Code v${pkg.version}`),
|
|
257
|
+
React.createElement(Newline, null),
|
|
258
|
+
React.createElement(Text, { dimColor: true }, ' ' + '\u2500'.repeat(44)),
|
|
259
|
+
React.createElement(Newline, null),
|
|
260
|
+
React.createElement(Text, null,
|
|
261
|
+
' Detected: ',
|
|
262
|
+
React.createElement(Text, { bold: true }, env.shell),
|
|
263
|
+
env.hasClaudeCode
|
|
264
|
+
? React.createElement(Text, null, ' + ', React.createElement(Text, { color: 'cyan' }, 'Claude Code'))
|
|
265
|
+
: React.createElement(Text, { dimColor: true }, ' (no Claude Code)')
|
|
266
|
+
),
|
|
267
|
+
React.createElement(Text, null,
|
|
268
|
+
' Works with: ',
|
|
269
|
+
React.createElement(Text, { color: 'cyan' }, 'Claude Code'),
|
|
270
|
+
React.createElement(Text, { dimColor: true }, ' | Codex, Cursor -- coming soon')
|
|
271
|
+
),
|
|
272
|
+
React.createElement(Newline, null)
|
|
273
|
+
),
|
|
274
|
+
|
|
275
|
+
// Completed steps
|
|
276
|
+
plan ? React.createElement(Text, null, completedLine('Plan', PLAN_LABELS[plan])) : null,
|
|
277
|
+
wantProxy !== null ? React.createElement(Text, null, completedLine('Proxy', wantProxy ? 'start now' : 'skipped')) : null,
|
|
278
|
+
wantShell !== null ? React.createElement(Text, null, completedLine('Shell', wantShell ? `add to ~/${env.rcFile}` : 'manual')) : null,
|
|
279
|
+
statusPreset !== null && phase !== PHASE_STATUSLINE ? React.createElement(Text, null, completedLine('Status line', statusPreset === 'skip' ? 'skipped' : `${statusPreset[0].toUpperCase() + statusPreset.slice(1)} (${(PRESETS[statusPreset] || []).length})`)) : null,
|
|
280
|
+
|
|
281
|
+
// Active phase
|
|
282
|
+
phase === PHASE_PLAN ? React.createElement(PlanStep, { onSelect: handlePlanSelect }) : null,
|
|
283
|
+
phase === PHASE_PROXY ? React.createElement(ProxyStep, { onSelect: handleProxySelect }) : null,
|
|
284
|
+
phase === PHASE_SHELL ? React.createElement(ShellStep, { onSelect: handleShellSelect, rcFile: env.rcFile }) : null,
|
|
285
|
+
phase === PHASE_STATUSLINE ? React.createElement(StatusLineStep, { onSelect: handleStatusLineSelect }) : null,
|
|
286
|
+
phase === PHASE_PROCESSING ? React.createElement(ProcessingPhase, {
|
|
287
|
+
tasks: buildTaskList(),
|
|
288
|
+
taskIndex,
|
|
289
|
+
taskResults,
|
|
290
|
+
progress,
|
|
291
|
+
}) : null,
|
|
292
|
+
phase === PHASE_DONE ? React.createElement(DoneSummary, {
|
|
293
|
+
plan,
|
|
294
|
+
wantProxy,
|
|
295
|
+
wantShell,
|
|
296
|
+
statusPreset,
|
|
297
|
+
env,
|
|
298
|
+
taskResults,
|
|
299
|
+
}) : null
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Step Components ─────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function PlanStep({ onSelect }) {
|
|
306
|
+
return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
|
|
307
|
+
React.createElement(Text, { bold: true }, ' [1/4] Which Claude plan are you on?'),
|
|
308
|
+
React.createElement(Newline, null),
|
|
309
|
+
React.createElement(Box, { paddingLeft: 4 },
|
|
310
|
+
React.createElement(Select, {
|
|
311
|
+
options: [
|
|
312
|
+
{ label: 'Pro ~500K tokens / 5hr window', value: 'pro' },
|
|
313
|
+
{ label: 'Max ~2M tokens / 5hr window', value: 'max' },
|
|
314
|
+
{ label: 'API only (no plan limits)', value: 'api' },
|
|
315
|
+
],
|
|
316
|
+
onChange: onSelect,
|
|
317
|
+
})
|
|
318
|
+
)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function ProxyStep({ onSelect }) {
|
|
323
|
+
return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
|
|
324
|
+
React.createElement(Text, { bold: true }, ' [2/4] Start the proxy daemon?'),
|
|
325
|
+
React.createElement(Text, { dimColor: true }, ' Enables per-request tracking for detailed breakdowns.'),
|
|
326
|
+
React.createElement(Newline, null),
|
|
327
|
+
React.createElement(Box, { paddingLeft: 4 },
|
|
328
|
+
React.createElement(Select, {
|
|
329
|
+
options: [
|
|
330
|
+
{ label: 'Yes, start now', value: 'yes' },
|
|
331
|
+
{ label: 'No, skip', value: 'no' },
|
|
332
|
+
],
|
|
333
|
+
onChange: onSelect,
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function ShellStep({ onSelect, rcFile }) {
|
|
340
|
+
return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
|
|
341
|
+
React.createElement(Text, { bold: true }, ` [3/4] Add ANTHROPIC_BASE_URL to ~/${rcFile}?`),
|
|
342
|
+
React.createElement(Text, { dimColor: true }, ' Required for the proxy to intercept API calls.'),
|
|
343
|
+
React.createElement(Newline, null),
|
|
344
|
+
React.createElement(Box, { paddingLeft: 4 },
|
|
345
|
+
React.createElement(Select, {
|
|
346
|
+
options: [
|
|
347
|
+
{ label: 'Yes, add it', value: 'yes' },
|
|
348
|
+
{ label: 'No, I\'ll do it manually', value: 'no' },
|
|
349
|
+
],
|
|
350
|
+
onChange: onSelect,
|
|
351
|
+
})
|
|
352
|
+
)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function StatusLineStep({ onSelect }) {
|
|
357
|
+
const [selected, setSelected] = useState(null);
|
|
358
|
+
|
|
359
|
+
const handleChange = useCallback((value) => {
|
|
360
|
+
setSelected(value);
|
|
361
|
+
onSelect(value);
|
|
362
|
+
}, [onSelect]);
|
|
363
|
+
|
|
364
|
+
return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
|
|
365
|
+
React.createElement(Text, { bold: true }, ' [4/4] Configure Claude Code status line?'),
|
|
366
|
+
React.createElement(Newline, null),
|
|
367
|
+
React.createElement(Box, { paddingLeft: 4 },
|
|
368
|
+
React.createElement(Select, {
|
|
369
|
+
options: [
|
|
370
|
+
{ label: 'Recommended model | ctx% | repo | limits | cost', value: 'recommended' },
|
|
371
|
+
{ label: 'Minimal model | current rate limit', value: 'minimal' },
|
|
372
|
+
{ label: 'Full everything including burn rate', value: 'full' },
|
|
373
|
+
{ label: 'Skip', value: 'skip' },
|
|
374
|
+
],
|
|
375
|
+
onChange: handleChange,
|
|
376
|
+
})
|
|
377
|
+
),
|
|
378
|
+
// Preview box shown after selection or for the default
|
|
379
|
+
!selected ? React.createElement(Box, {
|
|
380
|
+
borderStyle: 'round',
|
|
381
|
+
borderColor: 'gray',
|
|
382
|
+
paddingX: 1,
|
|
383
|
+
marginTop: 1,
|
|
384
|
+
marginLeft: 4,
|
|
385
|
+
},
|
|
386
|
+
React.createElement(Box, { flexDirection: 'column' },
|
|
387
|
+
React.createElement(Text, { dimColor: true }, 'Preview (Recommended):'),
|
|
388
|
+
...previewForPreset('recommended').split('\n').map((line, i) =>
|
|
389
|
+
React.createElement(Text, { key: `p-${i}` }, line)
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
) : null
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Processing Phase ────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
function ProcessingPhase({ tasks, taskIndex, taskResults, progress }) {
|
|
399
|
+
return React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
400
|
+
React.createElement(Text, { bold: true }, ' Setting up tokburn...'),
|
|
401
|
+
React.createElement(Newline, null),
|
|
402
|
+
React.createElement(Box, { paddingLeft: 2 },
|
|
403
|
+
React.createElement(ProgressBar, { value: progress })
|
|
404
|
+
),
|
|
405
|
+
React.createElement(Newline, null),
|
|
406
|
+
...tasks.map((task, i) => {
|
|
407
|
+
if (i < taskIndex) {
|
|
408
|
+
// Done
|
|
409
|
+
return React.createElement(Text, { key: task.key },
|
|
410
|
+
' ', React.createElement(Text, { color: 'green' }, '\u2713'),
|
|
411
|
+
' ', task.label,
|
|
412
|
+
taskResults[task.key] && taskResults[task.key].pid ? ` (PID ${taskResults[task.key].pid})` : ''
|
|
413
|
+
);
|
|
414
|
+
} else if (i === taskIndex) {
|
|
415
|
+
// Active
|
|
416
|
+
return React.createElement(Box, { key: task.key },
|
|
417
|
+
React.createElement(Text, null, ' '),
|
|
418
|
+
React.createElement(Spinner, { label: task.label })
|
|
419
|
+
);
|
|
420
|
+
} else {
|
|
421
|
+
// Pending
|
|
422
|
+
return React.createElement(Text, { key: task.key, dimColor: true },
|
|
423
|
+
' \u25CB ', task.label
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Done Summary ────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
function DoneSummary({ plan, wantProxy, wantShell, statusPreset, env, taskResults }) {
|
|
433
|
+
const proxyResult = taskResults.proxy || {};
|
|
434
|
+
const shellResult = taskResults.shell || {};
|
|
435
|
+
|
|
436
|
+
const proxyDesc = !wantProxy ? 'skipped'
|
|
437
|
+
: proxyResult.pid ? `started :${proxyResult.pid}`
|
|
438
|
+
: proxyResult.message || 'started';
|
|
439
|
+
|
|
440
|
+
const shellDesc = !wantShell ? 'skipped'
|
|
441
|
+
: shellResult.added ? `added to ~/${env.rcFile}`
|
|
442
|
+
: shellResult.reason === 'already exists' ? 'already in rc'
|
|
443
|
+
: 'configured';
|
|
444
|
+
|
|
445
|
+
const statusDesc = !statusPreset || statusPreset === 'skip' ? 'skipped'
|
|
446
|
+
: `${statusPreset[0].toUpperCase() + statusPreset.slice(1)} (${(PRESETS[statusPreset] || []).length})`;
|
|
447
|
+
|
|
448
|
+
return React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
449
|
+
React.createElement(Text, { dimColor: true }, ` ${'─'.repeat(2)} Setup complete ${'─'.repeat(25)}`),
|
|
450
|
+
React.createElement(Newline, null),
|
|
451
|
+
React.createElement(Text, null, completedLine('Plan', PLAN_LABELS[plan])),
|
|
452
|
+
React.createElement(Text, null, completedLine('Proxy', proxyDesc)),
|
|
453
|
+
React.createElement(Text, null, completedLine('Shell', shellDesc)),
|
|
454
|
+
React.createElement(Text, null, completedLine('Status line', statusDesc)),
|
|
455
|
+
React.createElement(Newline, null),
|
|
456
|
+
React.createElement(Text, { bold: true }, ' Try these:'),
|
|
457
|
+
React.createElement(Text, null, ' ', React.createElement(Text, { color: 'cyan' }, 'tokburn status'), ' check everything'),
|
|
458
|
+
React.createElement(Text, null, ' ', React.createElement(Text, { color: 'cyan' }, 'tokburn today'), ' see today\'s usage'),
|
|
459
|
+
React.createElement(Text, null, ' ', React.createElement(Text, { color: 'cyan' }, 'tokburn live'), ' real-time dashboard'),
|
|
460
|
+
React.createElement(Newline, null)
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
render(React.createElement(App));
|
package/init.js
CHANGED
|
@@ -257,4 +257,70 @@ async function runInit() {
|
|
|
257
257
|
rl.close();
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
|
|
260
|
+
// ── Exported config functions (used by Ink UI) ──────────────────────────────
|
|
261
|
+
|
|
262
|
+
function detectEnvironment() {
|
|
263
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
264
|
+
const shell = path.basename(process.env.SHELL || 'bash');
|
|
265
|
+
const rcFile = shell === 'zsh' ? '.zshrc' : shell === 'fish' ? '.config/fish/config.fish' : '.bashrc';
|
|
266
|
+
const rcPath = path.join(home, rcFile);
|
|
267
|
+
const claudeDir = path.join(home, '.claude');
|
|
268
|
+
const claudeSettings = path.join(claudeDir, 'settings.json');
|
|
269
|
+
const hasClaudeCode = fs.existsSync(claudeDir);
|
|
270
|
+
|
|
271
|
+
return { home, shell, rcFile, rcPath, claudeDir, claudeSettings, hasClaudeCode };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function configurePlan(plan) {
|
|
275
|
+
setConfig({ plan, limits: PLANS });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function configureProxy() {
|
|
279
|
+
if (isRunning()) {
|
|
280
|
+
return { success: true, message: 'already running', pid: null };
|
|
281
|
+
}
|
|
282
|
+
return startDaemon();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function configureShell(rcPath, port) {
|
|
286
|
+
const envLine = `export ANTHROPIC_BASE_URL=http://127.0.0.1:${port}`;
|
|
287
|
+
const existing = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : '';
|
|
288
|
+
if (existing.includes('ANTHROPIC_BASE_URL')) {
|
|
289
|
+
return { added: false, reason: 'already exists' };
|
|
290
|
+
}
|
|
291
|
+
fs.appendFileSync(rcPath, '\n# tokburn proxy\n' + envLine + '\n');
|
|
292
|
+
return { added: true };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function configureStatusLine(selectedModules) {
|
|
296
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
297
|
+
const claudeDir = path.join(home, '.claude');
|
|
298
|
+
const claudeSettings = path.join(claudeDir, 'settings.json');
|
|
299
|
+
|
|
300
|
+
setConfig({ statusline_modules: selectedModules });
|
|
301
|
+
|
|
302
|
+
const srcScript = path.join(__dirname, 'statusline.js');
|
|
303
|
+
const destScript = path.join(claudeDir, 'tokburn-statusline.js');
|
|
304
|
+
|
|
305
|
+
if (fs.existsSync(srcScript)) {
|
|
306
|
+
fs.copyFileSync(srcScript, destScript);
|
|
307
|
+
fs.chmodSync(destScript, '755');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let settings = {};
|
|
311
|
+
if (fs.existsSync(claudeSettings)) {
|
|
312
|
+
try { settings = JSON.parse(fs.readFileSync(claudeSettings, 'utf8')); } catch (_) {}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
settings.statusLine = { type: 'command', command: destScript };
|
|
316
|
+
|
|
317
|
+
if (!fs.existsSync(claudeDir)) {
|
|
318
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
319
|
+
}
|
|
320
|
+
fs.writeFileSync(claudeSettings, JSON.stringify(settings, null, 2) + '\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = {
|
|
324
|
+
runInit, detectEnvironment, configurePlan, configureProxy,
|
|
325
|
+
configureShell, configureStatusLine, PLANS
|
|
326
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokburn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "See exactly how fast you're burning tokens and money across Claude Code sessions",
|
|
5
5
|
"main": "cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"costs.js",
|
|
16
16
|
"config.js",
|
|
17
17
|
"init.js",
|
|
18
|
+
"init-ui.mjs",
|
|
18
19
|
"card.js",
|
|
19
20
|
"statusline.js",
|
|
20
21
|
"statusline.sh",
|
|
@@ -55,6 +56,10 @@
|
|
|
55
56
|
"node": ">=18.0.0"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|
|
58
|
-
"
|
|
59
|
+
"@inkjs/ui": "^2.0.0",
|
|
60
|
+
"chalk": "^5.6.2",
|
|
61
|
+
"commander": "^12.0.0",
|
|
62
|
+
"ink": "^6.8.0",
|
|
63
|
+
"react": "^19.2.4"
|
|
59
64
|
}
|
|
60
65
|
}
|
package/statusline.js
CHANGED
|
@@ -3,37 +3,16 @@
|
|
|
3
3
|
* tokburn — Status line renderer for Claude Code
|
|
4
4
|
* Reads session JSON from stdin, renders configured modules.
|
|
5
5
|
* Configured via ~/.tokburn/config.json → statusline_modules
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Stdin reading and rendering only happens when run directly.
|
|
8
|
+
* When require()'d as a module, only MODULE_LIST, PRESETS are exported.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
const fs = require('fs');
|
|
9
12
|
const path = require('path');
|
|
10
13
|
const { execFileSync } = require('child_process');
|
|
11
14
|
|
|
12
|
-
//
|
|
13
|
-
let input = '';
|
|
14
|
-
try {
|
|
15
|
-
input = fs.readFileSync('/dev/stdin', 'utf8');
|
|
16
|
-
} catch (_) {}
|
|
17
|
-
|
|
18
|
-
let data = {};
|
|
19
|
-
try {
|
|
20
|
-
data = JSON.parse(input);
|
|
21
|
-
} catch (_) {}
|
|
22
|
-
|
|
23
|
-
// Load config
|
|
24
|
-
const configPath = path.join(process.env.HOME || process.env.USERPROFILE, '.tokburn', 'config.json');
|
|
25
|
-
let config = {};
|
|
26
|
-
try {
|
|
27
|
-
if (fs.existsSync(configPath)) {
|
|
28
|
-
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
29
|
-
}
|
|
30
|
-
} catch (_) {}
|
|
31
|
-
|
|
32
|
-
const enabledModules = config.statusline_modules || [
|
|
33
|
-
'model_context', 'repo_branch', 'current_limit', 'weekly_limit', 'cost'
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
// ── Module renderers ────────────────────────────────────────────────────────────
|
|
15
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────────
|
|
37
16
|
|
|
38
17
|
function dotBar(pct, count) {
|
|
39
18
|
count = count || 10;
|
|
@@ -64,7 +43,6 @@ function formatResetTime(resetTimestamp) {
|
|
|
64
43
|
const remainMins = mins % 60;
|
|
65
44
|
if (hrs < 24) return hrs + 'hr ' + (remainMins > 0 ? remainMins + 'min' : '');
|
|
66
45
|
|
|
67
|
-
// Show day + time for >24hr
|
|
68
46
|
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
69
47
|
const day = days[reset.getDay()];
|
|
70
48
|
const h = reset.getHours();
|
|
@@ -74,86 +52,90 @@ function formatResetTime(resetTimestamp) {
|
|
|
74
52
|
return day + ' ' + h12 + ':' + String(m).padStart(2, '0') + ampm;
|
|
75
53
|
}
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
55
|
+
// ── Module builders (take data as parameter) ────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function buildModules(data) {
|
|
58
|
+
return {
|
|
59
|
+
model_context: function () {
|
|
60
|
+
const model = (data.model && data.model.display_name) || '?';
|
|
61
|
+
const ctxPct = Math.round((data.context_window && data.context_window.used_percentage) || 0);
|
|
62
|
+
return model + ' | ctx ' + ctxPct + '%';
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
repo_branch: function () {
|
|
66
|
+
const cwd = (data.workspace && data.workspace.current_dir) || data.cwd || '';
|
|
67
|
+
const repoName = path.basename(cwd);
|
|
68
|
+
let branch = '';
|
|
69
|
+
try {
|
|
70
|
+
branch = execFileSync('git', ['-C', cwd, 'branch', '--show-current'], {
|
|
71
|
+
encoding: 'utf8', timeout: 500, stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
}).trim();
|
|
73
|
+
const status = execFileSync('git', ['-C', cwd, 'status', '--porcelain'], {
|
|
74
|
+
encoding: 'utf8', timeout: 500, stdio: ['pipe', 'pipe', 'pipe'],
|
|
75
|
+
}).trim();
|
|
76
|
+
if (status) branch += '*';
|
|
77
|
+
} catch (_) {}
|
|
78
|
+
|
|
79
|
+
if (branch) return repoName + ' (' + branch + ')';
|
|
80
|
+
return repoName;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
current_limit: function () {
|
|
84
|
+
const rl = data.rate_limits && data.rate_limits.five_hour;
|
|
85
|
+
if (!rl) return 'current ' + dotBar(0) + ' 0%';
|
|
86
|
+
|
|
87
|
+
const pct = Math.round(rl.used_percentage || 0);
|
|
88
|
+
const reset = formatResetTime(rl.resets_at);
|
|
89
|
+
return 'current ' + dotBar(pct) + ' ' + pct + '%' + (reset ? ' \u21BB ' + reset : '');
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
weekly_limit: function () {
|
|
93
|
+
const rl = data.rate_limits && data.rate_limits.seven_day;
|
|
94
|
+
if (!rl) return 'weekly ' + dotBar(0) + ' 0%';
|
|
95
|
+
|
|
96
|
+
const pct = Math.round(rl.used_percentage || 0);
|
|
97
|
+
const reset = formatResetTime(rl.resets_at);
|
|
98
|
+
return 'weekly ' + dotBar(pct) + ' ' + pct + '%' + (reset ? ' \u21BB ' + reset : '');
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
token_count: function () {
|
|
102
|
+
const inp = (data.context_window && data.context_window.total_input_tokens) || 0;
|
|
103
|
+
const out = (data.context_window && data.context_window.total_output_tokens) || 0;
|
|
104
|
+
return abbreviate(inp + out) + ' tok';
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
cost: function () {
|
|
108
|
+
const cost = (data.cost && data.cost.total_cost_usd) || 0;
|
|
109
|
+
return '$' + cost.toFixed(2);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
burn_rate: function () {
|
|
113
|
+
try {
|
|
114
|
+
const usagePath = path.join(process.env.HOME || '', '.tokburn', 'usage.jsonl');
|
|
115
|
+
if (!fs.existsSync(usagePath)) return '';
|
|
116
|
+
const raw = fs.readFileSync(usagePath, 'utf8').trim();
|
|
117
|
+
if (!raw) return '';
|
|
118
|
+
const lines = raw.split('\n');
|
|
119
|
+
const today = new Date().toISOString().split('T')[0];
|
|
120
|
+
const todayEntries = [];
|
|
121
|
+
for (const l of lines) {
|
|
122
|
+
if (!l.startsWith('{"timestamp":"' + today)) continue;
|
|
123
|
+
try { todayEntries.push(JSON.parse(l)); } catch (_) {}
|
|
124
|
+
}
|
|
125
|
+
if (todayEntries.length < 2) return '';
|
|
126
|
+
const first = new Date(todayEntries[0].timestamp);
|
|
127
|
+
const last = new Date(todayEntries[todayEntries.length - 1].timestamp);
|
|
128
|
+
const elapsed = (last - first) / 60000;
|
|
129
|
+
if (elapsed <= 0) return '';
|
|
130
|
+
let total = 0;
|
|
131
|
+
for (const e of todayEntries) total += (e.input_tokens || 0) + (e.output_tokens || 0);
|
|
132
|
+
return '~' + abbreviate(Math.round(total / elapsed)) + '/min';
|
|
133
|
+
} catch (_) {
|
|
134
|
+
return '';
|
|
143
135
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const elapsed = (last - first) / 60000;
|
|
148
|
-
if (elapsed <= 0) return '';
|
|
149
|
-
let total = 0;
|
|
150
|
-
for (const e of todayEntries) total += (e.input_tokens || 0) + (e.output_tokens || 0);
|
|
151
|
-
return '~' + abbreviate(Math.round(total / elapsed)) + '/min';
|
|
152
|
-
} catch (_) {
|
|
153
|
-
return '';
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
};
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
157
139
|
|
|
158
140
|
// ── Available modules metadata (used by init wizard) ────────────────────────────
|
|
159
141
|
|
|
@@ -175,16 +157,38 @@ const PRESETS = {
|
|
|
175
157
|
full: ['model_context', 'repo_branch', 'current_limit', 'weekly_limit', 'token_count', 'cost', 'burn_rate'],
|
|
176
158
|
};
|
|
177
159
|
|
|
178
|
-
// ──
|
|
160
|
+
// ── Main: only runs when executed directly (not require'd) ──────────────────────
|
|
179
161
|
|
|
180
162
|
if (require.main === module) {
|
|
163
|
+
let input = '';
|
|
164
|
+
try {
|
|
165
|
+
input = fs.readFileSync('/dev/stdin', 'utf8');
|
|
166
|
+
} catch (_) {}
|
|
167
|
+
|
|
168
|
+
let data = {};
|
|
169
|
+
try {
|
|
170
|
+
data = JSON.parse(input);
|
|
171
|
+
} catch (_) {}
|
|
172
|
+
|
|
173
|
+
// Load config
|
|
174
|
+
const configPath = path.join(process.env.HOME || process.env.USERPROFILE, '.tokburn', 'config.json');
|
|
175
|
+
let config = {};
|
|
176
|
+
try {
|
|
177
|
+
if (fs.existsSync(configPath)) {
|
|
178
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
179
|
+
}
|
|
180
|
+
} catch (_) {}
|
|
181
|
+
|
|
182
|
+
const enabledModules = config.statusline_modules || PRESETS.recommended;
|
|
183
|
+
const modules = buildModules(data);
|
|
184
|
+
|
|
181
185
|
const outputLines = [];
|
|
182
186
|
const lineOneModules = [];
|
|
183
187
|
const extraLines = [];
|
|
184
188
|
|
|
185
189
|
for (const mod of enabledModules) {
|
|
186
|
-
if (!
|
|
187
|
-
const val =
|
|
190
|
+
if (!modules[mod]) continue;
|
|
191
|
+
const val = modules[mod]();
|
|
188
192
|
if (!val) continue;
|
|
189
193
|
|
|
190
194
|
if (mod === 'current_limit' || mod === 'weekly_limit') {
|
|
@@ -202,4 +206,4 @@ if (require.main === module) {
|
|
|
202
206
|
process.stdout.write(outputLines.join('\n'));
|
|
203
207
|
}
|
|
204
208
|
|
|
205
|
-
module.exports = { MODULE_LIST, PRESETS,
|
|
209
|
+
module.exports = { MODULE_LIST, PRESETS, buildModules };
|