claude-code-hud 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/README.md +161 -0
- package/bin/claude-hud +31 -0
- package/bin/start.mjs +31 -0
- package/commands/hud.md +40 -0
- package/hooks/hooks.json +29 -0
- package/package.json +44 -0
- package/scripts/full-hud.mjs +35 -0
- package/scripts/lib/formatter.mjs +131 -0
- package/scripts/lib/git-info.mjs +67 -0
- package/scripts/lib/token-reader.mjs +212 -0
- package/scripts/lib/usage-api.mjs +141 -0
- package/scripts/session-start.mjs +42 -0
- package/scripts/statusline.mjs +46 -0
- package/scripts/stop-hud.mjs +31 -0
- package/skills/hud.md +45 -0
- package/tui/hud.tsx +645 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage from ~/.claude/projects/ JSONL session files.
|
|
3
|
+
* No external dependencies — pure Node.js.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
|
|
9
|
+
const CONTEXT_WINDOWS = {
|
|
10
|
+
'claude-opus-4': 200000,
|
|
11
|
+
'claude-sonnet-4': 200000,
|
|
12
|
+
'claude-haiku-4': 200000,
|
|
13
|
+
'claude-3-5-sonnet': 200000,
|
|
14
|
+
'claude-3-5-haiku': 200000,
|
|
15
|
+
'claude-3-opus': 200000,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const PRICING = {
|
|
19
|
+
opus: { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
20
|
+
sonnet: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
21
|
+
haiku: { input: 0.8, output: 4.0, cacheRead: 0.08, cacheWrite: 1.0 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function getPricing(model) {
|
|
25
|
+
if (model.includes('opus')) return PRICING.opus;
|
|
26
|
+
if (model.includes('haiku')) return PRICING.haiku;
|
|
27
|
+
return PRICING.sonnet;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getContextWindow(model) {
|
|
31
|
+
for (const [key, val] of Object.entries(CONTEXT_WINDOWS)) {
|
|
32
|
+
if (model.includes(key)) return val;
|
|
33
|
+
}
|
|
34
|
+
return 200000;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Find the most recently modified .jsonl session file */
|
|
38
|
+
function findLatestSession() {
|
|
39
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
40
|
+
if (!fs.existsSync(projectsDir)) return null;
|
|
41
|
+
|
|
42
|
+
let latest = null;
|
|
43
|
+
let latestMtime = 0;
|
|
44
|
+
|
|
45
|
+
const projects = fs.readdirSync(projectsDir);
|
|
46
|
+
for (const proj of projects) {
|
|
47
|
+
const projDir = path.join(projectsDir, proj);
|
|
48
|
+
|
|
49
|
+
// Claude Code stores sessions as UUID.jsonl directly in the project dir
|
|
50
|
+
let files = [];
|
|
51
|
+
try {
|
|
52
|
+
files = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl') && !f.includes('/'));
|
|
53
|
+
} catch { continue; }
|
|
54
|
+
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
const fullPath = path.join(projDir, file);
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.statSync(fullPath);
|
|
59
|
+
if (stat.mtimeMs > latestMtime) {
|
|
60
|
+
latestMtime = stat.mtimeMs;
|
|
61
|
+
latest = fullPath;
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return latest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Collect all JSONL lines across all sessions with their timestamps */
|
|
70
|
+
function readAllLines() {
|
|
71
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
72
|
+
if (!fs.existsSync(projectsDir)) return [];
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const proj of fs.readdirSync(projectsDir)) {
|
|
75
|
+
const projDir = path.join(projectsDir, proj);
|
|
76
|
+
let files = [];
|
|
77
|
+
try { files = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const fullPath = path.join(projDir, file);
|
|
80
|
+
const fileMtime = fs.statSync(fullPath).mtimeMs;
|
|
81
|
+
try {
|
|
82
|
+
const lines = fs.readFileSync(fullPath, 'utf8').split('\n').filter(Boolean);
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
try {
|
|
85
|
+
const obj = JSON.parse(line);
|
|
86
|
+
if (!obj.message?.usage) continue;
|
|
87
|
+
const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : fileMtime;
|
|
88
|
+
result.push({ ts, usage: obj.message.usage, model: obj.message.model || 'claude-sonnet-4' });
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function readTokenHistory() {
|
|
98
|
+
const allLines = readAllLines();
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const h5 = now - 5 * 60 * 60 * 1000;
|
|
101
|
+
const wk = now - 7 * 24 * 60 * 60 * 1000;
|
|
102
|
+
const h12 = now - 12 * 60 * 60 * 1000;
|
|
103
|
+
|
|
104
|
+
const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
|
105
|
+
const acc5h = empty(), accWk = empty();
|
|
106
|
+
|
|
107
|
+
// 12 hourly buckets (index 0 = oldest, 11 = most recent)
|
|
108
|
+
const buckets = Array(12).fill(0);
|
|
109
|
+
|
|
110
|
+
for (const { ts, usage, model } of allLines) {
|
|
111
|
+
const pricing = getPricing(model);
|
|
112
|
+
const M = 1_000_000;
|
|
113
|
+
const inp = usage.input_tokens || 0;
|
|
114
|
+
const out = usage.output_tokens || 0;
|
|
115
|
+
const cr = usage.cache_read_input_tokens || 0;
|
|
116
|
+
const cw = usage.cache_creation_input_tokens || 0;
|
|
117
|
+
|
|
118
|
+
const addTo = (acc) => {
|
|
119
|
+
acc.inputTokens += inp;
|
|
120
|
+
acc.outputTokens += out;
|
|
121
|
+
acc.cacheReadTokens += cr;
|
|
122
|
+
acc.cacheWriteTokens += cw;
|
|
123
|
+
acc.cost.input += (inp / M) * pricing.input;
|
|
124
|
+
acc.cost.output += (out / M) * pricing.output;
|
|
125
|
+
acc.cost.cacheRead += (cr / M) * pricing.cacheRead;
|
|
126
|
+
acc.cost.cacheWrite += (cw / M) * pricing.cacheWrite;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (ts >= wk) { addTo(accWk); }
|
|
130
|
+
if (ts >= h5) { addTo(acc5h); }
|
|
131
|
+
|
|
132
|
+
if (ts >= h12) {
|
|
133
|
+
const hoursAgo = (now - ts) / (60 * 60 * 1000);
|
|
134
|
+
const idx = Math.min(11, Math.floor(12 - hoursAgo));
|
|
135
|
+
if (idx >= 0) buckets[idx] += out;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
[acc5h, accWk].forEach(acc => {
|
|
140
|
+
acc.cost.total = acc.cost.input + acc.cost.output + acc.cost.cacheRead + acc.cost.cacheWrite;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { last5h: acc5h, lastWeek: accWk, hourlyBuckets: buckets };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function readTokenUsage() {
|
|
147
|
+
const sessionFile = findLatestSession();
|
|
148
|
+
if (!sessionFile) {
|
|
149
|
+
return empty();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let model = 'claude-sonnet-4';
|
|
153
|
+
// For cost: sum all turns' output + input (billed per turn)
|
|
154
|
+
let totalOutputTokens = 0;
|
|
155
|
+
let totalInputTokens = 0;
|
|
156
|
+
let totalCacheReadTokens = 0;
|
|
157
|
+
let totalCacheWriteTokens = 0;
|
|
158
|
+
// For context window: use the LAST turn's snapshot (what's in context right now)
|
|
159
|
+
let lastUsage = null;
|
|
160
|
+
|
|
161
|
+
const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(Boolean);
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
try {
|
|
164
|
+
const obj = JSON.parse(line);
|
|
165
|
+
if (obj.message?.model) model = obj.message.model;
|
|
166
|
+
|
|
167
|
+
const usage = obj.message?.usage;
|
|
168
|
+
if (!usage) continue;
|
|
169
|
+
|
|
170
|
+
// Accumulate output tokens (main cost driver, each turn is new output)
|
|
171
|
+
totalOutputTokens += usage.output_tokens || 0;
|
|
172
|
+
totalInputTokens += usage.input_tokens || 0;
|
|
173
|
+
totalCacheReadTokens += usage.cache_read_input_tokens || 0;
|
|
174
|
+
totalCacheWriteTokens += usage.cache_creation_input_tokens || 0;
|
|
175
|
+
lastUsage = usage;
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!lastUsage) return empty();
|
|
180
|
+
|
|
181
|
+
// Context window = what's currently loaded (last turn's input side)
|
|
182
|
+
const ctxInput = lastUsage.input_tokens || 0;
|
|
183
|
+
const ctxCacheR = lastUsage.cache_read_input_tokens || 0;
|
|
184
|
+
const ctxCacheW = lastUsage.cache_creation_input_tokens || 0;
|
|
185
|
+
const contextUsed = ctxInput + ctxCacheR + ctxCacheW;
|
|
186
|
+
|
|
187
|
+
const contextWindow = getContextWindow(model);
|
|
188
|
+
const pricing = getPricing(model);
|
|
189
|
+
const M = 1_000_000;
|
|
190
|
+
const cost = {
|
|
191
|
+
input: (totalInputTokens / M) * pricing.input,
|
|
192
|
+
output: (totalOutputTokens / M) * pricing.output,
|
|
193
|
+
cacheRead: (totalCacheReadTokens / M) * pricing.cacheRead,
|
|
194
|
+
cacheWrite: (totalCacheWriteTokens / M) * pricing.cacheWrite,
|
|
195
|
+
};
|
|
196
|
+
cost.total = cost.input + cost.output + cost.cacheRead + cost.cacheWrite;
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
inputTokens: totalInputTokens,
|
|
200
|
+
outputTokens: totalOutputTokens,
|
|
201
|
+
cacheReadTokens: totalCacheReadTokens,
|
|
202
|
+
cacheWriteTokens: totalCacheWriteTokens,
|
|
203
|
+
totalTokens: contextUsed, // context window usage = current ctx size
|
|
204
|
+
contextWindow,
|
|
205
|
+
model,
|
|
206
|
+
cost,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function empty() {
|
|
211
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0, contextWindow: 200000, model: 'unknown', cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
212
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic OAuth usage API
|
|
3
|
+
* Replicates OMC's approach: Keychain → ~/.claude/.credentials.json → API call
|
|
4
|
+
* Endpoint: api.anthropic.com/api/oauth/usage
|
|
5
|
+
* Response: { five_hour: { utilization, resets_at }, seven_day: { utilization, resets_at } }
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import https from 'https';
|
|
12
|
+
|
|
13
|
+
const CACHE_TTL_OK = 5 * 60 * 1000; // 5 minutes for success
|
|
14
|
+
const CACHE_TTL_ERR = 30 * 1000; // 30s for failure
|
|
15
|
+
const CACHE_PATH = join(homedir(), '.claude', '.hud-usage-cache.json');
|
|
16
|
+
|
|
17
|
+
function readCache() {
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(CACHE_PATH)) return null;
|
|
20
|
+
const c = JSON.parse(readFileSync(CACHE_PATH, 'utf-8'));
|
|
21
|
+
// Re-hydrate Date objects from ISO strings
|
|
22
|
+
if (c.data) {
|
|
23
|
+
if (c.data.fiveHourResetsAt) c.data.fiveHourResetsAt = new Date(c.data.fiveHourResetsAt);
|
|
24
|
+
if (c.data.weeklyResetsAt) c.data.weeklyResetsAt = new Date(c.data.weeklyResetsAt);
|
|
25
|
+
}
|
|
26
|
+
return c;
|
|
27
|
+
} catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeCache(data, error = false) {
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(CACHE_PATH, JSON.stringify({ ts: Date.now(), data, error }));
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isCacheValid(c) {
|
|
37
|
+
const ttl = c.error ? CACHE_TTL_ERR : CACHE_TTL_OK;
|
|
38
|
+
return Date.now() - c.ts < ttl;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCredentials() {
|
|
42
|
+
// macOS Keychain first
|
|
43
|
+
if (process.platform === 'darwin') {
|
|
44
|
+
try {
|
|
45
|
+
const raw = execSync(
|
|
46
|
+
'/usr/bin/security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
|
47
|
+
{ encoding: 'utf-8', timeout: 2000 }
|
|
48
|
+
).trim();
|
|
49
|
+
if (raw) {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
const creds = parsed.claudeAiOauth || parsed;
|
|
52
|
+
if (creds.accessToken) return creds;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
// Fallback to file
|
|
57
|
+
try {
|
|
58
|
+
const credPath = join(homedir(), '.claude', '.credentials.json');
|
|
59
|
+
if (existsSync(credPath)) {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(credPath, 'utf-8'));
|
|
61
|
+
const creds = parsed.claudeAiOauth || parsed;
|
|
62
|
+
if (creds.accessToken) return creds;
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fetchUsage(accessToken) {
|
|
69
|
+
return new Promise(resolve => {
|
|
70
|
+
const req = https.request({
|
|
71
|
+
hostname: 'api.anthropic.com',
|
|
72
|
+
path: '/api/oauth/usage',
|
|
73
|
+
method: 'GET',
|
|
74
|
+
headers: {
|
|
75
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
76
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
},
|
|
79
|
+
timeout: 8000,
|
|
80
|
+
}, res => {
|
|
81
|
+
let data = '';
|
|
82
|
+
res.on('data', chunk => { data += chunk; });
|
|
83
|
+
res.on('end', () => {
|
|
84
|
+
if (res.statusCode === 200) {
|
|
85
|
+
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
86
|
+
} else {
|
|
87
|
+
resolve(null);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
req.on('error', () => resolve(null));
|
|
92
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
93
|
+
req.end();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Synchronously returns cached usage data if still valid, else null.
|
|
99
|
+
* Use this for initial render to avoid showing "--" before first async call.
|
|
100
|
+
*/
|
|
101
|
+
export function getUsageSync() {
|
|
102
|
+
const cache = readCache();
|
|
103
|
+
if (cache && isCacheValid(cache)) return cache.data;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns { fiveHourPercent, weeklyPercent, fiveHourResetsAt, weeklyResetsAt }
|
|
109
|
+
* or null if credentials not available / API call failed
|
|
110
|
+
*/
|
|
111
|
+
export async function getUsage() {
|
|
112
|
+
const cache = readCache();
|
|
113
|
+
if (cache && isCacheValid(cache)) return cache.data;
|
|
114
|
+
|
|
115
|
+
const creds = getCredentials();
|
|
116
|
+
if (!creds) { writeCache(null, true); return null; }
|
|
117
|
+
|
|
118
|
+
// Check expiry
|
|
119
|
+
if (creds.expiresAt != null && creds.expiresAt <= Date.now()) {
|
|
120
|
+
writeCache(null, true); return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const response = await fetchUsage(creds.accessToken);
|
|
124
|
+
if (!response) { writeCache(null, true); return null; }
|
|
125
|
+
|
|
126
|
+
const clamp = v => (v == null || !isFinite(v)) ? 0 : Math.max(0, Math.min(100, v));
|
|
127
|
+
const parseDate = s => { try { const d = new Date(s); return isNaN(d.getTime()) ? null : d; } catch { return null; } };
|
|
128
|
+
|
|
129
|
+
const fiveHour = response.five_hour?.utilization;
|
|
130
|
+
const sevenDay = response.seven_day?.utilization;
|
|
131
|
+
if (fiveHour == null && sevenDay == null) { writeCache(null, true); return null; }
|
|
132
|
+
|
|
133
|
+
const result = {
|
|
134
|
+
fiveHourPercent: clamp(fiveHour),
|
|
135
|
+
weeklyPercent: clamp(sevenDay),
|
|
136
|
+
fiveHourResetsAt: parseDate(response.five_hour?.resets_at),
|
|
137
|
+
weeklyResetsAt: parseDate(response.seven_day?.resets_at),
|
|
138
|
+
};
|
|
139
|
+
writeCache(result, false);
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* HUD — SessionStart hook
|
|
4
|
+
* Shows: project git branch + token baseline at session start
|
|
5
|
+
*/
|
|
6
|
+
import { readTokenUsage } from './lib/token-reader.mjs';
|
|
7
|
+
import { readGitInfo } from './lib/git-info.mjs';
|
|
8
|
+
import { tokenLine, gitLine, divider, fmtCost } from './lib/formatter.mjs';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
|
|
12
|
+
let raw = '';
|
|
13
|
+
try {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
16
|
+
raw = Buffer.concat(chunks).toString();
|
|
17
|
+
} catch {}
|
|
18
|
+
const input = JSON.parse(raw || '{}');
|
|
19
|
+
const cwd = input.cwd || input.directory || process.env.CLAUDE_PROJECT_ROOT || process.cwd();
|
|
20
|
+
|
|
21
|
+
const usage = readTokenUsage();
|
|
22
|
+
const git = readGitInfo(cwd);
|
|
23
|
+
|
|
24
|
+
// Quick file count
|
|
25
|
+
let fileCount = '?';
|
|
26
|
+
try {
|
|
27
|
+
fileCount = execSync('git ls-files 2>/dev/null | wc -l', { cwd, encoding: 'utf8' }).trim();
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
const D = divider(54);
|
|
31
|
+
const lines = [
|
|
32
|
+
`◆ HUD`,
|
|
33
|
+
D,
|
|
34
|
+
tokenLine(usage),
|
|
35
|
+
gitLine(git),
|
|
36
|
+
`files ${fileCount.trim()}`,
|
|
37
|
+
D,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const message = lines.join('\n');
|
|
41
|
+
|
|
42
|
+
process.stdout.write(JSON.stringify({ continue: true, message }) + '\n');
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* HUD — statusLine script
|
|
4
|
+
* Runs every few seconds, outputs a single line shown at the bottom of Claude Code.
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
|
|
10
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const { readTokenUsage } = await import(join(__dir, 'lib/token-reader.mjs'));
|
|
12
|
+
const { readGitInfo } = await import(join(__dir, 'lib/git-info.mjs'));
|
|
13
|
+
const { fmtK, fmtCost, statusLabel } = await import(join(__dir, 'lib/formatter.mjs'));
|
|
14
|
+
|
|
15
|
+
const cwd = process.env.CLAUDE_PROJECT_ROOT || process.cwd();
|
|
16
|
+
|
|
17
|
+
const usage = readTokenUsage();
|
|
18
|
+
const git = readGitInfo(cwd);
|
|
19
|
+
|
|
20
|
+
// Token status
|
|
21
|
+
const pct = Math.round((usage.totalTokens / usage.contextWindow) * 100);
|
|
22
|
+
const st = statusLabel(usage.totalTokens, usage.contextWindow);
|
|
23
|
+
const tokStr = `${fmtK(usage.totalTokens)}/${fmtK(usage.contextWindow)} ${st}`;
|
|
24
|
+
|
|
25
|
+
// Cost
|
|
26
|
+
const costStr = fmtCost(usage.cost.total);
|
|
27
|
+
|
|
28
|
+
// Git
|
|
29
|
+
let gitStr = '';
|
|
30
|
+
if (git.isRepo) {
|
|
31
|
+
gitStr = `⎇ ${git.branch}`;
|
|
32
|
+
if (git.totalChanges > 0) gitStr += ` +${git.totalChanges}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Model (short)
|
|
36
|
+
const model = usage.model.replace('claude-', '').replace(/-\d{8}$/, '');
|
|
37
|
+
|
|
38
|
+
const parts = [
|
|
39
|
+
`◆ HUD`,
|
|
40
|
+
`tok ${tokStr}`,
|
|
41
|
+
costStr,
|
|
42
|
+
gitStr,
|
|
43
|
+
model,
|
|
44
|
+
].filter(Boolean);
|
|
45
|
+
|
|
46
|
+
process.stdout.write(parts.join(' │ ') + '\n');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* HUD — Stop hook
|
|
4
|
+
* Shows compact token bar after each Claude response.
|
|
5
|
+
*/
|
|
6
|
+
import { readTokenUsage } from './lib/token-reader.mjs';
|
|
7
|
+
import { readGitInfo } from './lib/git-info.mjs';
|
|
8
|
+
import { tokenLine, gitLine, divider } from './lib/formatter.mjs';
|
|
9
|
+
|
|
10
|
+
let raw = '';
|
|
11
|
+
try {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
14
|
+
raw = Buffer.concat(chunks).toString();
|
|
15
|
+
} catch {}
|
|
16
|
+
const input = JSON.parse(raw || '{}');
|
|
17
|
+
const cwd = input.cwd || input.directory || process.env.CLAUDE_PROJECT_ROOT || process.cwd();
|
|
18
|
+
|
|
19
|
+
const usage = readTokenUsage();
|
|
20
|
+
const git = readGitInfo(cwd);
|
|
21
|
+
|
|
22
|
+
const D = divider(54);
|
|
23
|
+
const lines = [
|
|
24
|
+
`◆ HUD`,
|
|
25
|
+
D,
|
|
26
|
+
tokenLine(usage),
|
|
27
|
+
gitLine(git),
|
|
28
|
+
D,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
process.stdout.write(JSON.stringify({ continue: true, message: lines.join('\n') }) + '\n');
|
package/skills/hud.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hud
|
|
3
|
+
description: Show full HUD dashboard — token usage, cost breakdown, git status. Trigger with /hud.
|
|
4
|
+
triggers:
|
|
5
|
+
- "/hud"
|
|
6
|
+
- "hud 보여줘"
|
|
7
|
+
- "토큰 얼마나 썼어"
|
|
8
|
+
- "show hud"
|
|
9
|
+
- "hud status"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Run this Node.js script inline and show the result as a formatted dashboard:
|
|
13
|
+
|
|
14
|
+
```javascript
|
|
15
|
+
// Read token usage from ~/.claude/projects JSONL
|
|
16
|
+
import { readTokenUsage } from '${CLAUDE_PLUGIN_ROOT}/scripts/lib/token-reader.mjs';
|
|
17
|
+
import { readGitInfo } from '${CLAUDE_PLUGIN_ROOT}/scripts/lib/git-info.mjs';
|
|
18
|
+
import { tokenPanel, gitPanel, divider } from '${CLAUDE_PLUGIN_ROOT}/scripts/lib/formatter.mjs';
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When the user invokes /hud, execute the following shell command and show the output:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
node -e "
|
|
25
|
+
import('${CLAUDE_PLUGIN_ROOT}/scripts/lib/token-reader.mjs').then(async ({ readTokenUsage }) => {
|
|
26
|
+
const { readGitInfo } = await import('${CLAUDE_PLUGIN_ROOT}/scripts/lib/git-info.mjs');
|
|
27
|
+
const { tokenPanel, gitPanel, divider, fmtCost } = await import('${CLAUDE_PLUGIN_ROOT}/scripts/lib/formatter.mjs');
|
|
28
|
+
const usage = readTokenUsage();
|
|
29
|
+
const git = readGitInfo(process.cwd());
|
|
30
|
+
const D = divider(54);
|
|
31
|
+
console.log('◆ HUD — Full Dashboard');
|
|
32
|
+
console.log(D);
|
|
33
|
+
console.log('');
|
|
34
|
+
console.log('[TOKENS]');
|
|
35
|
+
console.log(tokenPanel(usage));
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log('[GIT]');
|
|
38
|
+
console.log(gitPanel(git));
|
|
39
|
+
console.log('');
|
|
40
|
+
console.log(D);
|
|
41
|
+
});
|
|
42
|
+
"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Display the output in a code block with no modification. If the script fails, show the error and suggest running \`/plugin install jhhan/hud-plugin\` to reinstall.
|