@jv-k/claude-gauge 0.0.1 → 1.1.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.
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // claude-gauge token line for Claude Code.
4
+ //
5
+ // Reports the tokens spent since your last prompt, from the session
6
+ // transcript, as one compact line:
7
+ //
8
+ // 12:10 │ 4 req │ out 3.4k (1.2k think) │ cache w6.5k r1.69M │ ctx 43% ▓▓░░░ 427k
9
+ //
10
+ // Two ways to run it:
11
+ // - as a Stop hook: Claude Code pipes the hook payload on stdin and shows
12
+ // the line as a system message when the turn ends;
13
+ // - with --latest: prints the line for the calling session, for Claude to
14
+ // paste into its reply where hook messages are not shown.
15
+ //
16
+ // Switches:
17
+ // --show <parts> parts to show, in order, comma-separated, from
18
+ // time,req,out,cache,ctx (default: all of them)
19
+ // --segments <5|10> cells in the context bar (default 5)
20
+ // --window <tokens> context window size, e.g. 200k or 1m. Without it the
21
+ // window is 200k, or 1M once the context passes 200k.
22
+ // --latest print the line for the calling session
23
+ // --instruct as a SessionStart hook: in the VS Code extension and
24
+ // the desktop app, tell Claude to end each reply with
25
+ // the line; in the terminal CLI, print nothing
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.INSTRUCT_HOSTS = exports.PARTS = void 0;
28
+ exports.summarize = summarize;
29
+ exports.parseArgs = parseArgs;
30
+ exports.readSwitches = readSwitches;
31
+ exports.parseSize = parseSize;
32
+ exports.contextWindow = contextWindow;
33
+ exports.instruction = instruction;
34
+ const fs = require("node:fs");
35
+ const os = require("node:os");
36
+ const path = require("node:path");
37
+ const PARTS = ['time', 'req', 'out', 'cache', 'ctx'];
38
+ exports.PARTS = PARTS;
39
+ const isPart = (name) => PARTS.includes(name);
40
+ // The switches that take a value. parseArgs reads each of them.
41
+ const TAKES_VALUE = ['--show', '--segments', '--window'];
42
+ // The switches in `argv` as the token line reads them: a switch that takes a
43
+ // value takes the next word, unless it has one after `=`. Any other word
44
+ // stands alone.
45
+ function readSwitches(argv) {
46
+ const read = [];
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const start = i;
49
+ const [name, inline] = argv[i].split(/=(.*)/s);
50
+ const value = TAKES_VALUE.includes(name) ? (inline ?? argv[++i] ?? '') : '';
51
+ read.push({ name, value, words: argv.slice(start, i + 1) });
52
+ }
53
+ return read;
54
+ }
55
+ // Turns the switches into options. Unknown switches and part names are
56
+ // ignored, so a hook never fails over a typo.
57
+ function parseArgs(argv) {
58
+ const opts = { latest: false };
59
+ for (const { name, value } of readSwitches(argv)) {
60
+ switch (name) {
61
+ case '--show': {
62
+ const parts = value.split(',').map((p) => p.trim()).filter(isPart);
63
+ if (parts.length)
64
+ opts.show = parts;
65
+ break;
66
+ }
67
+ case '--segments':
68
+ opts.segments = value;
69
+ break;
70
+ case '--window':
71
+ opts.window = value;
72
+ break;
73
+ case '--latest':
74
+ opts.latest = true;
75
+ break;
76
+ default: break;
77
+ }
78
+ }
79
+ return opts;
80
+ }
81
+ // Compact counts: 1.69M, 427k, 6.5k, 830.
82
+ const fmt = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(2)}M`
83
+ : n >= 1e5 ? `${Math.round(n / 1e3)}k`
84
+ : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k`
85
+ : String(n);
86
+ const SCALES = { '': 1, k: 1e3, m: 1e6 };
87
+ // Parses "200000", "200k" or "1m" into a token count; NaN when it is none.
88
+ function parseSize(value) {
89
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([km]?)\s*$/i.exec(String(value ?? ''));
90
+ if (!m)
91
+ return NaN;
92
+ const scale = SCALES[m[2].toLowerCase()];
93
+ return Math.round(Number(m[1]) * scale);
94
+ }
95
+ // The transcript records the model but not its context window, so take the
96
+ // window from --window when set. Otherwise assume Claude Code's default of
97
+ // 200k, and 1M once the context is past that.
98
+ function contextWindow(ctx, explicit) {
99
+ const size = parseSize(explicit);
100
+ if (size > 0)
101
+ return size;
102
+ return ctx > 200e3 ? 1e6 : 200e3;
103
+ }
104
+ // Bars come in 5 or 10 cells; anything else falls back to 5.
105
+ const segmentsOf = (value) => (Number(value) === 10 ? 10 : 5);
106
+ // A bar of `segments` cells over the context window.
107
+ const bar = (pct, segments) => {
108
+ const filled = Math.min(segments, Math.max(0, Math.round((pct * segments) / 100)));
109
+ return '▓'.repeat(filled) + '░'.repeat(segments - filled);
110
+ };
111
+ // Local HH:MM, enough to tell runs apart in a scrollback.
112
+ const stamp = (d) => {
113
+ const pad = (n) => String(n).padStart(2, '0');
114
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
115
+ };
116
+ // A human prompt, as opposed to a tool_result echoed back as a user record.
117
+ function isHumanPrompt(rec) {
118
+ if (rec.type !== 'user' || rec.isMeta || rec.isSidechain)
119
+ return false;
120
+ const content = rec.message?.content;
121
+ if (typeof content === 'string')
122
+ return true;
123
+ if (Array.isArray(content))
124
+ return !content.some((b) => b?.type === 'tool_result');
125
+ return false;
126
+ }
127
+ function tally(records) {
128
+ const t = { reqs: 0, out: 0, thinking: 0, cacheWrite: 0, cacheRead: 0, ctx: 0 };
129
+ // The transcript writes one record per content block (thinking, text, each
130
+ // tool_use), and every one of them carries the whole message's usage. Count
131
+ // each API response once, by its message id, keeping its last record.
132
+ const byMessage = new Map();
133
+ for (const r of records) {
134
+ if (r.message?.usage)
135
+ byMessage.set(r.message.id ?? r.uuid, r);
136
+ }
137
+ for (const r of byMessage.values()) {
138
+ const u = r.message?.usage ?? {};
139
+ t.reqs += 1;
140
+ t.out += u.output_tokens ?? 0;
141
+ t.thinking += u.output_tokens_details?.thinking_tokens ?? 0;
142
+ t.cacheWrite += u.cache_creation_input_tokens ?? 0;
143
+ t.cacheRead += u.cache_read_input_tokens ?? 0;
144
+ // Context at the end of the run is whatever the final main-thread request
145
+ // carried in; subagent (sidechain) requests have their own context.
146
+ if (!r.isSidechain) {
147
+ t.ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
148
+ }
149
+ }
150
+ return t;
151
+ }
152
+ // Builds the line from parsed transcript records; null when the run made no
153
+ // API request yet, or when none of the chosen parts has anything to show.
154
+ function summarize(records, { now = new Date(), window, segments, show = PARTS } = {}) {
155
+ let start = 0;
156
+ for (let i = records.length - 1; i >= 0; i--) {
157
+ if (isHumanPrompt(records[i])) {
158
+ start = i;
159
+ break;
160
+ }
161
+ }
162
+ const run = tally(records.slice(start).filter((r) => r.type === 'assistant'));
163
+ if (run.reqs === 0)
164
+ return null;
165
+ const build = {
166
+ time: () => stamp(now),
167
+ req: () => `${run.reqs} req`,
168
+ // Thinking tokens are part of output_tokens, so show them inside it.
169
+ out: () => (run.thinking ? `out ${fmt(run.out)} (${fmt(run.thinking)} think)` : `out ${fmt(run.out)}`),
170
+ cache: () => `cache w${fmt(run.cacheWrite)} r${fmt(run.cacheRead)}`,
171
+ ctx: () => {
172
+ if (!run.ctx)
173
+ return '';
174
+ const pct = (run.ctx / contextWindow(run.ctx, window)) * 100;
175
+ return `ctx ${Math.round(pct)}% ${bar(pct, segmentsOf(segments))} ${fmt(run.ctx)}`;
176
+ },
177
+ };
178
+ // A show list handed in from JavaScript may name parts the registry lacks;
179
+ // those render as nothing, like a part with nothing to show.
180
+ const anyPart = build;
181
+ // The same separator as the status line.
182
+ const line = show.map((part) => anyPart[part]?.() ?? '').filter(Boolean).join(' │ ');
183
+ return line || null;
184
+ }
185
+ function readRecords(transcriptPath) {
186
+ const records = [];
187
+ for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
188
+ if (!line)
189
+ continue;
190
+ try {
191
+ records.push(JSON.parse(line));
192
+ }
193
+ catch {
194
+ /* a partially flushed final line is expected */
195
+ }
196
+ }
197
+ return records;
198
+ }
199
+ // Claude Code's config folder: $CLAUDE_CONFIG_DIR when it is set and not
200
+ // empty, else ~/.claude. The status line resolves it the same way, with its
201
+ // own copy, because each script runs alone from the copy that setup makes.
202
+ const configDirOf = (env, home) => env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
203
+ function latestTranscript(cwd) {
204
+ const projects = path.join(configDirOf(process.env, os.homedir()), 'projects');
205
+ if (!fs.existsSync(projects))
206
+ return null;
207
+ // Claude Code exports the calling session's id to the commands it runs.
208
+ // Prefer it: with several sessions open in one project, the newest
209
+ // transcript is often another session's. Search every project folder, so a
210
+ // `cd` into a worktree does not lose the session.
211
+ const own = process.env.CLAUDE_CODE_SESSION_ID;
212
+ if (own) {
213
+ for (const d of fs.readdirSync(projects)) {
214
+ const f = path.join(projects, d, `${own}.jsonl`);
215
+ if (fs.existsSync(f))
216
+ return f;
217
+ }
218
+ }
219
+ const dir = path.join(projects, cwd.replace(/[/.]/g, '-'));
220
+ if (!fs.existsSync(dir))
221
+ return null;
222
+ const newest = fs
223
+ .readdirSync(dir)
224
+ .filter((f) => f.endsWith('.jsonl'))
225
+ .map((f) => path.join(dir, f))
226
+ .map((f) => ({ f, mtime: fs.statSync(f).mtimeMs }))
227
+ .sort((a, b) => b.mtime - a.mtime)[0];
228
+ return newest ? newest.f : null;
229
+ }
230
+ // --instruct: the SessionStart hook for hosts that show no hook message. In
231
+ // the VS Code extension and the desktop app it prints an instruction that has
232
+ // Claude end each reply with it. In the terminal CLI, where it already shows,
233
+ // it prints nothing. Claude Code names the host in CLAUDE_CODE_ENTRYPOINT,
234
+ // which hooks inherit; an unknown or missing value counts as a host that
235
+ // needs nothing.
236
+ const INSTRUCT_HOSTS = ['claude-vscode', 'claude-desktop', 'claude-desktop-3p'];
237
+ exports.INSTRUCT_HOSTS = INSTRUCT_HOSTS;
238
+ // A shell word: as is when plain, else in single quotes. ~ stays bare so the
239
+ // shell expands a ~/ path.
240
+ const shellWord = (s) => (/^[\w@%+=:,./~-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
241
+ function instruction(argv, { host, script }) {
242
+ if (host === undefined || !INSTRUCT_HOSTS.includes(host))
243
+ return null;
244
+ const command = ['node', script, '--latest', ...argv.filter((a) => a !== '--instruct')].map(shellWord).join(' ');
245
+ return [
246
+ '## Token line in replies',
247
+ '',
248
+ "End every reply with the claude-gauge token line, as a copyable code block, so this panel shows what the terminal's Stop hook does:",
249
+ '',
250
+ '```sh',
251
+ command,
252
+ '```',
253
+ '',
254
+ "Run it as the last tool call of the turn, then paste its line verbatim as the final thing in the reply, in one plain code block. Skip it only if the command fails or prints nothing. Never guess the figures, and never reuse an earlier turn's line.",
255
+ '',
256
+ ].join('\n');
257
+ }
258
+ // This script's path as a hook command can name it: ~ for the home folder.
259
+ const ownPath = () => process.argv[1].replace(new RegExp(`^${os.homedir()}(?=/)`), '~');
260
+ const isMain = (typeof require !== 'undefined' && require.main === module) || (typeof Bun !== 'undefined' && Bun.main === __filename);
261
+ if (isMain) {
262
+ const argv = process.argv.slice(2);
263
+ const { latest, ...opts } = parseArgs(argv);
264
+ if (argv.includes('--instruct')) {
265
+ const text = instruction(argv, { host: process.env.CLAUDE_CODE_ENTRYPOINT, script: ownPath() });
266
+ if (text)
267
+ process.stdout.write(text);
268
+ }
269
+ else if (latest) {
270
+ const t = latestTranscript(process.cwd());
271
+ const line = t ? summarize(readRecords(t), opts) : null;
272
+ if (line)
273
+ process.stdout.write(line + '\n');
274
+ }
275
+ else {
276
+ const chunks = [];
277
+ process.stdin.on('data', (c) => chunks.push(c));
278
+ process.stdin.on('end', () => {
279
+ let line = null;
280
+ try {
281
+ const input = JSON.parse(Buffer.concat(chunks).toString());
282
+ if (input.transcript_path && fs.existsSync(input.transcript_path)) {
283
+ line = summarize(readRecords(input.transcript_path), opts);
284
+ }
285
+ }
286
+ catch {
287
+ /* never break the turn over a reporting hook */
288
+ }
289
+ if (line)
290
+ process.stdout.write(JSON.stringify({ systemMessage: line }) + '\n');
291
+ });
292
+ }
293
+ }
package/dist/wizard.js ADDED
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ // The questions claude-gauge setup and configure ask when run with no bar
3
+ // switches: take the defaults in one answer, or walk through the status
4
+ // line's rows and parts, its bar size, theme and labels, and the token line
5
+ // and its parts, with a preview of the status line redrawn after each
6
+ // answer. configure starts from the bars set up now: the first answer keeps
7
+ // them, and each question offers the value set up. It returns the switches
8
+ // for each bar, as words, for the CLI to write; it reads and writes no
9
+ // settings itself.
10
+ //
11
+ // The questions go through a WizardIo, so a test can script the answers. The
12
+ // preview renders the payload the status line saved last, else a sample.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.EndOfAnswers = exports.confirm = void 0;
15
+ exports.runWizard = runWizard;
16
+ exports.offerStar = offerStar;
17
+ exports.samplePayload = samplePayload;
18
+ exports.loadPayload = loadPayload;
19
+ exports.previewer = previewer;
20
+ exports.switchesFor = switchesFor;
21
+ const fs = require("node:fs");
22
+ const statusline_1 = require("./statusline");
23
+ const tokenline_1 = require("./tokenline");
24
+ // The input ended before the last question: nothing is written.
25
+ class EndOfAnswers extends Error {
26
+ constructor() {
27
+ super('The answers ended before the last question. Nothing changed.');
28
+ }
29
+ }
30
+ exports.EndOfAnswers = EndOfAnswers;
31
+ const MAX_ROWS = 3;
32
+ const SEGMENTS = [5, 10];
33
+ const DEFAULT_CHOICES = { rows: statusline_1.DEFAULT_ROWS, segments: 5, theme: 'default', labels: true, others: [] };
34
+ // Whether two lists of parts, or of rows of parts, are the same, in order.
35
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
36
+ // The fewest switches that give `choices`: none for the defaults, then the
37
+ // switches the wizard does not ask about. A row not chosen yet is left out.
38
+ function switchesFor({ rows, segments, theme, labels, others }) {
39
+ const chosen = rows.filter((row) => row.length);
40
+ const words = [];
41
+ if (!same(chosen, statusline_1.DEFAULT_ROWS))
42
+ for (const row of chosen)
43
+ words.push('--show', row.join(','));
44
+ if (segments !== 5)
45
+ words.push('--segments', String(segments));
46
+ if (theme !== 'default')
47
+ words.push('--theme', theme);
48
+ if (!labels)
49
+ words.push('--no-labels');
50
+ return [...words, ...others];
51
+ }
52
+ // The status line choices that `switches` give, read as the status line reads
53
+ // them, so the last --segments or --theme wins. A part name or theme the
54
+ // status line does not know reads as the status line shows it: left out, or
55
+ // the default theme.
56
+ function statusChoicesOf(switches) {
57
+ const status = { ...DEFAULT_CHOICES, rows: [], others: [] };
58
+ for (const { name, value, words } of (0, statusline_1.readSwitches)(switches)) {
59
+ if (name === '--show') {
60
+ const row = value.split(',').map((p) => p.trim()).filter((p) => statusline_1.PARTS.includes(p));
61
+ if (row.length)
62
+ status.rows.push(row);
63
+ }
64
+ else if (name === '--segments')
65
+ status.segments = Number(value) === 10 ? 10 : 5;
66
+ else if (name === '--theme')
67
+ status.theme = statusline_1.THEMES.includes(value.trim()) ? value.trim() : 'default';
68
+ else if (name === '--no-labels')
69
+ status.labels = false;
70
+ else
71
+ status.others.push(...words);
72
+ }
73
+ if (!status.rows.length)
74
+ status.rows = statusline_1.DEFAULT_ROWS;
75
+ return status;
76
+ }
77
+ const isSetUp = (installed) => installed?.statusLine !== undefined || installed?.tokenLine !== undefined;
78
+ // The start from the bars set up, else from the factory defaults.
79
+ function startFrom(installed) {
80
+ if (!isSetUp(installed))
81
+ return { status: DEFAULT_CHOICES, tokenLine: true, tokenSwitches: [] };
82
+ return {
83
+ status: statusChoicesOf(installed.statusLine ?? []),
84
+ tokenLine: installed.tokenLine !== undefined,
85
+ tokenSwitches: installed.tokenLine ?? [],
86
+ };
87
+ }
88
+ // Asks until `read` takes the answer: it returns the value, or a retry that
89
+ // says why it cannot. Each `read` says what an empty answer, Enter, gives.
90
+ async function askFor(io, question, read) {
91
+ for (;;) {
92
+ const answer = await io.ask(question);
93
+ if (answer === undefined)
94
+ throw new EndOfAnswers();
95
+ const value = read(answer.trim());
96
+ if (typeof value === 'object' && value !== null && 'retry' in value) {
97
+ io.write(`${value.retry}\n`);
98
+ continue;
99
+ }
100
+ return value;
101
+ }
102
+ }
103
+ const yesNo = (fallback) => (answer) => {
104
+ if (!answer)
105
+ return fallback;
106
+ if (/^y(?:es)?$/i.test(answer))
107
+ return true;
108
+ if (/^no?$/i.test(answer))
109
+ return false;
110
+ return { retry: 'Answer y or n.' };
111
+ };
112
+ const confirm = (io, question, fallback) => askFor(io, `${question} ${fallback ? '[Y/n]' : '[y/N]'} `, yesNo(fallback));
113
+ exports.confirm = confirm;
114
+ // Reads a list of parts from `known`, or `fallback` on Enter. An unknown
115
+ // part is asked again, named, with `where` to say which parts there are.
116
+ function readParts(fallback, known, where = 'The parts are listed above.') {
117
+ return (answer) => {
118
+ if (!answer && fallback)
119
+ return fallback;
120
+ const names = answer.split(/[\s,]+/).filter(Boolean);
121
+ const unknown = names.filter((n) => !known.includes(n));
122
+ if (unknown.length)
123
+ return { retry: `Unknown part${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. ${where}` };
124
+ if (!names.length)
125
+ return { retry: 'Name at least one part.' };
126
+ return [...new Set(names)];
127
+ };
128
+ }
129
+ // The parts the token line shows with `switches`, read as the token line
130
+ // reads them: all of them without a --show it can use.
131
+ const tokenPartsOf = (switches) => (0, tokenline_1.parseArgs)(switches).show ?? [...tokenline_1.PARTS];
132
+ // The token line's switches for `parts`. Parts the installed switches show
133
+ // already keep those switches as they are. Other parts drop the installed
134
+ // --show and keep the rest. All five parts, in order, need no --show; any
135
+ // other list goes first, as --show <parts>.
136
+ function tokenSwitchesFor(parts, installed) {
137
+ if (same(parts, tokenPartsOf(installed)))
138
+ return installed;
139
+ const others = (0, tokenline_1.readSwitches)(installed).filter(({ name }) => name !== '--show').flatMap(({ words }) => words);
140
+ return same(parts, tokenline_1.PARTS) ? others : ['--show', parts.join(','), ...others];
141
+ }
142
+ // Reads the token line's parts: `all` for all five, else a list as readParts
143
+ // reads it.
144
+ function readTokenParts(fallback) {
145
+ const list = readParts(fallback, tokenline_1.PARTS, `The token line parts are ${tokenline_1.PARTS.join(', ')}.`);
146
+ return (answer) => (/^all$/i.test(answer) ? [...tokenline_1.PARTS] : list(answer));
147
+ }
148
+ async function runWizard(io, { preview, installed }) {
149
+ const start = startFrom(installed);
150
+ let status = { ...start.status };
151
+ let tokenLine = start.tokenLine;
152
+ const show = () => {
153
+ const rows = preview(switchesFor(status));
154
+ io.write(`\n${rows.split('\n').map((row) => ` ${row}`).join('\n')}\n`);
155
+ io.write(`Token line: ${tokenLine ? 'on, when each turn ends' : 'off'}\n\n`);
156
+ };
157
+ if (isSetUp(installed)) {
158
+ io.write(installed.statusLine ? 'The status line as set up now:\n' : 'The status line is not set up. With the defaults it shows:\n');
159
+ show();
160
+ if (await confirm(io, 'Keep the current bars as they are?', true))
161
+ return {};
162
+ }
163
+ else {
164
+ io.write('The status line with the defaults:\n');
165
+ show();
166
+ if (await confirm(io, 'Use the defaults: both bars, with the parts above?', true))
167
+ return { statusLine: [], tokenLine: [] };
168
+ }
169
+ // A row set up, else the default row there.
170
+ const rowAt = (i) => start.status.rows[i] ?? statusline_1.DEFAULT_ROWS[i];
171
+ // More rows than the wizard offers stay possible when that many are set up.
172
+ const rowsNow = start.status.rows.length;
173
+ const maxRows = Math.max(MAX_ROWS, rowsNow);
174
+ io.write(`\nThe parts: ${statusline_1.PARTS.join(', ')}.\nREADME.md says what each shows. Press Enter to keep the value in brackets.\n`);
175
+ const count = await askFor(io, `How many status line rows, 1 to ${maxRows}? [${rowsNow}] `, (answer) => {
176
+ const n = answer ? Number(answer) : rowsNow;
177
+ return Number.isInteger(n) && n >= 1 && n <= maxRows ? n : { retry: `Answer a number from 1 to ${maxRows}.` };
178
+ });
179
+ status = { ...status, rows: Array.from({ length: count }, (_, i) => rowAt(i) ?? []) };
180
+ show();
181
+ for (let i = 0; i < count; i++) {
182
+ const fallback = rowAt(i);
183
+ const hint = fallback ? ` [${fallback.join(',')}]` : '';
184
+ const parts = await askFor(io, `Row ${i + 1}: the parts, comma-separated${hint} `, readParts(fallback, statusline_1.PARTS));
185
+ status = { ...status, rows: status.rows.map((row, j) => (j === i ? parts : row)) };
186
+ show();
187
+ }
188
+ const segments = await askFor(io, `Cells per bar, ${SEGMENTS.join(' or ')}? [${start.status.segments}] `, (answer) => SEGMENTS.find((n) => String(n) === (answer || String(start.status.segments))) ?? { retry: `Answer ${SEGMENTS.join(' or ')}.` });
189
+ status = { ...status, segments };
190
+ show();
191
+ const theme = await askFor(io, `Theme: ${statusline_1.THEMES.join(', ')}? [${start.status.theme}] `, (answer) => {
192
+ const name = answer.toLowerCase() || start.status.theme;
193
+ return statusline_1.THEMES.includes(name) ? name : { retry: `Unknown theme: ${answer}. Answer one of ${statusline_1.THEMES.join(', ')}.` };
194
+ });
195
+ status = { ...status, theme };
196
+ show();
197
+ status = { ...status, labels: await confirm(io, 'Labels in front of the values, such as ctx and 5h?', start.status.labels) };
198
+ show();
199
+ tokenLine = await confirm(io, 'Add the token line, shown when each turn ends?', start.tokenLine);
200
+ show();
201
+ let tokenSwitches = start.tokenSwitches;
202
+ if (tokenLine) {
203
+ const fallback = tokenPartsOf(start.tokenSwitches);
204
+ const question = `Token line parts: all, req,out,ctx, or your own list from ${tokenline_1.PARTS.join(', ')}? [${fallback.join(',')}] `;
205
+ tokenSwitches = tokenSwitchesFor(await askFor(io, question, readTokenParts(fallback)), start.tokenSwitches);
206
+ }
207
+ if (!(await confirm(io, 'Write these choices?', true)))
208
+ return null;
209
+ return { statusLine: switchesFor(status), tokenLine: tokenLine ? tokenSwitches : null };
210
+ }
211
+ const REPO = 'jv-k/claude-gauge';
212
+ // The end of setup: with gh at hand, offers to star the repo, and stars it
213
+ // only on a yes. Without gh it says nothing. The end of the input is a no.
214
+ async function offerStar(io, { hasGh, star }) {
215
+ if (!hasGh())
216
+ return;
217
+ let yes;
218
+ try {
219
+ yes = await confirm(io, `Star ${REPO} on GitHub with gh?`, false);
220
+ }
221
+ catch (err) {
222
+ if (err instanceof EndOfAnswers)
223
+ return;
224
+ throw err;
225
+ }
226
+ if (!yes)
227
+ return;
228
+ io.write(star() ? `Starred ${REPO}. Thank you.\n` : `gh could not star ${REPO}. Nothing else changed.\n`);
229
+ }
230
+ // A status payload to preview with when the status line has saved none:
231
+ // two-fifths of the context, and a 5-hour and a weekly window part used.
232
+ function samplePayload(nowMs) {
233
+ const at = (ms) => Math.floor((nowMs + ms) / 1000);
234
+ const hour = 3600 * 1000;
235
+ return {
236
+ model: { display_name: 'Opus' },
237
+ effort: { level: 'high' },
238
+ workspace: { current_dir: process.cwd() },
239
+ context_window: { context_window_size: 200000, used_percentage: 43 },
240
+ rate_limits: {
241
+ five_hour: { used_percentage: 9, resets_at: at(2 * hour) },
242
+ seven_day: { used_percentage: 41, resets_at: at(3 * 24 * hour) },
243
+ },
244
+ cost: { total_duration_ms: 72 * 60 * 1000 },
245
+ };
246
+ }
247
+ // The payload the status line saved, or the sample when there is none, or
248
+ // none that reads as a payload: a JSON object that holds something.
249
+ function loadPayload(file, nowMs = Date.now()) {
250
+ try {
251
+ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
252
+ if (typeof saved === 'object' && saved !== null && !Array.isArray(saved) && Object.keys(saved).length)
253
+ return saved;
254
+ }
255
+ catch {
256
+ /* no payload saved yet, or half of one */
257
+ }
258
+ return samplePayload(nowMs);
259
+ }
260
+ // The preview: the status line's rows for `payload`, with the given switches
261
+ // as words, as the settings command passes them.
262
+ function previewer(payload, options = {}) {
263
+ return (switches) => (0, statusline_1.render)(payload, { ...options, config: (0, statusline_1.parseArgs)([...switches]) });
264
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ // The claude-gauge wordmark, which the CLI prints above its usage and at the
3
+ // start of the setup and configure questions. It is figlet's "future" font,
4
+ // as jv-k/deslopper draws its own, kept here as text so the CLI needs no
5
+ // figlet: three rows of one 3-cell chunk per character of "claude-gauge".
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.wordmark = wordmark;
8
+ exports.colorEnabled = colorEnabled;
9
+ const WORDMARK = [
10
+ ['┏━╸', '╻ ', '┏━┓', '╻ ╻', '╺┳┓', '┏━╸', ' ', '┏━╸', '┏━┓', '╻ ╻', '┏━╸', '┏━╸'],
11
+ ['┃ ', '┃ ', '┣━┫', '┃ ┃', ' ┃┃', '┣╸ ', '╺━╸', '┃╺┓', '┣━┫', '┃ ┃', '┃╺┓', '┣╸ '],
12
+ ['┗━╸', '┗━╸', '╹ ╹', '┗━┛', '╺┻┛', '┗━╸', ' ', '┗━┛', '╹ ╹', '┗━┛', '┗━┛', '┗━╸'],
13
+ ];
14
+ // A 256-colour number for each chunk: deslopper's rainbow widened to the 11
15
+ // letters, with 27 for blue because 21 reads poorly on a dark background, and
16
+ // grey for the hyphen.
17
+ const COLORS = [196, 202, 208, 214, 226, 118, 244, 82, 39, 27, 93, 163];
18
+ const RESET = '\x1b[0m';
19
+ // Whether to colour what goes to `stream`, in deslopper's order: a non-empty
20
+ // NO_COLOR turns colour off, a FORCE_COLOR or CLICOLOR_FORCE that is neither
21
+ // empty nor 0 turns it on, and otherwise only a terminal gets colour.
22
+ function colorEnabled(stream, env = process.env) {
23
+ if (env.NO_COLOR)
24
+ return false;
25
+ if (['CLICOLOR_FORCE', 'FORCE_COLOR'].some((name) => env[name] && env[name] !== '0'))
26
+ return true;
27
+ return stream.isTTY === true;
28
+ }
29
+ // The wordmark's three rows, each ending in a newline. In colour each chunk
30
+ // starts with its colour code and each row ends with a reset.
31
+ function wordmark(color) {
32
+ const row = (chunks) => color ? chunks.map((chunk, i) => `\x1b[38;5;${COLORS[i]}m${chunk}`).join('') + RESET : chunks.join('');
33
+ return WORDMARK.map((chunks) => row(chunks) + '\n').join('');
34
+ }
package/package.json CHANGED
@@ -1,16 +1,38 @@
1
1
  {
2
2
  "name": "@jv-k/claude-gauge",
3
- "version": "0.0.1",
4
- "description": "Placeholder. claude-gauge 1.0.0 is a status line and a token line for Claude Code.",
3
+ "version": "1.1.0",
4
+ "description": "A status line and a token line for Claude Code: context, 5-hour and weekly usage with pace markers.",
5
5
  "license": "MIT",
6
6
  "author": "John Valai",
7
- "homepage": "https://github.com/jv-k/claude-gauge#readme",
8
7
  "repository": {
9
8
  "type": "git",
10
9
  "url": "git+https://github.com/jv-k/claude-gauge.git"
11
10
  },
12
- "files": [],
11
+ "bin": {
12
+ "claude-gauge": "dist/cli.js",
13
+ "claude-gauge-statusline": "dist/statusline.js",
14
+ "claude-gauge-tokenline": "dist/tokenline.js"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
13
19
  "publishConfig": {
14
20
  "access": "public"
21
+ },
22
+ "packageManager": "pnpm@10.27.0",
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "biome lint --error-on-warnings",
30
+ "test": "pnpm build && node scripts/test.js",
31
+ "bump-release": "verbump --push origin"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "2.5.15",
35
+ "@types/node": "^18.19.130",
36
+ "typescript": "^5.9.3"
15
37
  }
16
38
  }