aqua-wasted 0.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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/bin/cli.js +272 -0
- package/package.json +42 -0
- package/statusline.js +196 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mehdev67
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# aqua-wasted 💧
|
|
2
|
+
|
|
3
|
+
Every token your AI burns runs in a data center that drinks fresh water to stay cool. **aqua-wasted** puts that hidden cost right in your Claude Code statusline, so you watch it climb while you work.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
⬆ /build │ Opus 4.7 │ myproject 💧 Aqua wasted 2 130 ml (≈ 4 bottles)
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It is a fun, slightly guilty little awareness tool. The number is real enough to make you think, and grounded in published research (sources below).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
One command, zero dependencies. Claude Code already ships Node, so this just works.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx aqua-wasted install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That drops a tiny script into `~/.claude/aqua-wasted/` and wires your `~/.claude/settings.json`. Then run `/statusline` in Claude Code or start a new session.
|
|
20
|
+
|
|
21
|
+
Already have a statusline? aqua-wasted detects it and runs it first, then appends the water segment after it. Your line is kept, not replaced.
|
|
22
|
+
|
|
23
|
+
Want it scarier or quieter:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx aqua-wasted install --spicy # bigger numbers, still under the studied maximum
|
|
27
|
+
npx aqua-wasted install --conservative # honest figures for modern efficient models
|
|
28
|
+
npx aqua-wasted install --unit=dl # ml is the default, dl and L also work
|
|
29
|
+
npx aqua-wasted install --no-bottles # drop the bottle count
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Uninstall
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx aqua-wasted uninstall
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This restores the statusline you had before, or removes ours if there was none. Your settings are backed up to `settings.json.bak-<timestamp>` on every install, just in case.
|
|
39
|
+
|
|
40
|
+
## The lifetime brag card
|
|
41
|
+
|
|
42
|
+
See the total water across every session you have ever run:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx aqua-wasted card --unit=L
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
💧 aqua-wasted lifetime report
|
|
50
|
+
════════════════════════════════════════════
|
|
51
|
+
sessions counted : 312
|
|
52
|
+
tokens burned : 84 200 000
|
|
53
|
+
water wasted : 2 526 L
|
|
54
|
+
bottles : 5 052 (500 ml each)
|
|
55
|
+
showers : 36.1
|
|
56
|
+
════════════════════════════════════════════
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How the number works
|
|
60
|
+
|
|
61
|
+
Token usage is summed straight from your Claude Code session transcript, so it reflects everything the session has actually generated and read. Generating tokens costs far more water than reading context, so output and input tokens are weighted separately. Three tiers ship in the box:
|
|
62
|
+
|
|
63
|
+
| Tier | output token | input token | basis |
|
|
64
|
+
|------|--------------|-------------|-------|
|
|
65
|
+
| conservative | 0.01 ml | 0.002 ml | modern, efficient 2025 models |
|
|
66
|
+
| headline (default) | 0.03 ml | 0.0075 ml | maps 1:1 to the UC Riverside figures |
|
|
67
|
+
| spicy | 0.1 ml | 0.025 ml | GPT 4 era, hot region, still below the studied maximum |
|
|
68
|
+
|
|
69
|
+
Edit `~/.claude/aqua-wasted/config.json` any time to change tier, unit, label, emoji, or color.
|
|
70
|
+
|
|
71
|
+
## True comparisons you can quote
|
|
72
|
+
|
|
73
|
+
- A 100 word AI email pours out about a bottle of water (519 ml), straight from the UC Riverside and Washington Post reporting.
|
|
74
|
+
- Roughly 16,500 generated tokens drink one 500 ml bottle.
|
|
75
|
+
- One million tokens is about 30 liters, two full kitchen sinks.
|
|
76
|
+
- A heavy coding week of around 2.5 million tokens is about 75 liters, one full shower.
|
|
77
|
+
- Training GPT 3 once evaporated 700,000 liters of clean freshwater, before anyone typed a single prompt.
|
|
78
|
+
|
|
79
|
+
## Honest disclaimer
|
|
80
|
+
|
|
81
|
+
These water figures are rough, illustrative estimates. Actual water use per token varies enormously by model, data center location, cooling technology, electricity grid, and season, easily by ten times or more, and includes both the on site cooling water and the water used to generate the electricity. They are meant to raise awareness of a real but hard to pin down cost, not to be a precise measurement.
|
|
82
|
+
|
|
83
|
+
Primary sources:
|
|
84
|
+
|
|
85
|
+
- Li, Yang, Islam, Ren. "Making AI Less Thirsty." UC Riverside, 2023, peer reviewed in Communications of the ACM, 2025. https://arxiv.org/abs/2304.03271
|
|
86
|
+
- Washington Post, "AI is exhausting the power grid," September 2024.
|
|
87
|
+
- Epoch AI, "How much energy does ChatGPT use?", 2025.
|
|
88
|
+
|
|
89
|
+
## Use as a Claude Code plugin
|
|
90
|
+
|
|
91
|
+
Prefer the plugin browser:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
/plugin marketplace add mehdev67/aqua-wasted
|
|
95
|
+
/plugin install aqua-wasted@aqua-wasted
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Then run the bundled command to wire it up:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
/aqua-wasted
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT. Use it, fork it, make the planet a tiny bit more visible.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// aqua-wasted CLI: install | uninstall | status | card | help
|
|
5
|
+
// Safely wires the statusline into Claude Code and renders a shareable card.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const core = require('../statusline.js');
|
|
11
|
+
|
|
12
|
+
const HOME = os.homedir();
|
|
13
|
+
const CLAUDE_DIR = path.join(HOME, '.claude');
|
|
14
|
+
const SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
|
|
15
|
+
const INSTALL_DIR = path.join(CLAUDE_DIR, 'aqua-wasted');
|
|
16
|
+
const RUNTIME = path.join(INSTALL_DIR, 'statusline.js');
|
|
17
|
+
const CONFIG = path.join(INSTALL_DIR, 'config.json');
|
|
18
|
+
const PKG_RUNTIME = path.join(__dirname, '..', 'statusline.js');
|
|
19
|
+
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
|
20
|
+
|
|
21
|
+
// --- small helpers ----------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function readJson(file, fallback) {
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.existsSync(file)) return fallback;
|
|
26
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
27
|
+
if (!raw.trim()) return fallback;
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return undefined; // signals a parse failure to the caller
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function atomicWrite(file, contents) {
|
|
35
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
36
|
+
fs.writeFileSync(tmp, contents);
|
|
37
|
+
fs.renameSync(tmp, file);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ourCommand() {
|
|
41
|
+
return 'node "' + RUNTIME + '"';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isOurCommand(cmd) {
|
|
45
|
+
return typeof cmd === 'string' && cmd.indexOf('aqua-wasted') !== -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseFlags(argv) {
|
|
49
|
+
const flags = {};
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i];
|
|
52
|
+
if (a === '--spicy') flags.tier = 'spicy';
|
|
53
|
+
else if (a === '--conservative') flags.tier = 'conservative';
|
|
54
|
+
else if (a === '--headline') flags.tier = 'headline';
|
|
55
|
+
else if (a === '--no-bottles') flags.bottles = false;
|
|
56
|
+
else if (a === '--no-color') flags.color = false;
|
|
57
|
+
else if (a === '--no-chain') flags.noChain = true;
|
|
58
|
+
else if (a.startsWith('--tier=')) flags.tier = a.slice(7);
|
|
59
|
+
else if (a.startsWith('--unit=')) flags.unit = a.slice(7);
|
|
60
|
+
else if (a.startsWith('--label=')) flags.label = a.slice(8);
|
|
61
|
+
else if (a.startsWith('--locale=')) flags.locale = a.slice(9);
|
|
62
|
+
else if (a.startsWith('--color=')) flags.color = a.slice(8);
|
|
63
|
+
}
|
|
64
|
+
return flags;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- commands ---------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function install(argv) {
|
|
70
|
+
const flags = parseFlags(argv);
|
|
71
|
+
|
|
72
|
+
const settings = readJson(SETTINGS, {});
|
|
73
|
+
if (settings === undefined) {
|
|
74
|
+
console.error('aqua-wasted: could not parse ' + SETTINGS + '. It may contain comments or be invalid JSON.');
|
|
75
|
+
console.error('Nothing was changed. Add this block manually instead:');
|
|
76
|
+
console.error(' "statusLine": { "type": "command", "command": ' + JSON.stringify(ourCommand()) + ' }');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Detect an existing statusLine so we can chain rather than clobber it.
|
|
81
|
+
let chain = null;
|
|
82
|
+
const existing = settings.statusLine;
|
|
83
|
+
if (existing && existing.type === 'command' && existing.command && !isOurCommand(existing.command)) {
|
|
84
|
+
if (!flags.noChain) {
|
|
85
|
+
chain = existing.command;
|
|
86
|
+
console.log('Found an existing statusLine. aqua-wasted will run it first and append after it.');
|
|
87
|
+
} else {
|
|
88
|
+
console.log('Found an existing statusLine. Replacing it (--no-chain).');
|
|
89
|
+
}
|
|
90
|
+
} else if (existing && existing.type !== 'command' && !isOurCommand(JSON.stringify(existing))) {
|
|
91
|
+
console.log('Found a non command statusLine. Leaving it and adding ours on top.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Write runtime + config into a stable location, independent of the npx cache.
|
|
95
|
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
|
96
|
+
fs.copyFileSync(PKG_RUNTIME, RUNTIME);
|
|
97
|
+
|
|
98
|
+
const cfg = Object.assign({}, core.DEFAULT_CONFIG, {
|
|
99
|
+
tier: flags.tier || core.DEFAULT_CONFIG.tier,
|
|
100
|
+
unit: flags.unit || core.DEFAULT_CONFIG.unit,
|
|
101
|
+
bottles: flags.bottles === false ? false : core.DEFAULT_CONFIG.bottles,
|
|
102
|
+
color: flags.color === false ? false : flags.color || core.DEFAULT_CONFIG.color,
|
|
103
|
+
label: flags.label || core.DEFAULT_CONFIG.label,
|
|
104
|
+
locale: flags.locale || core.DEFAULT_CONFIG.locale,
|
|
105
|
+
chain: chain,
|
|
106
|
+
});
|
|
107
|
+
if (!core.TIERS[cfg.tier]) {
|
|
108
|
+
console.error('aqua-wasted: unknown tier "' + cfg.tier + '". Use conservative, headline, or spicy.');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
atomicWrite(CONFIG, JSON.stringify(cfg, null, 2) + '\n');
|
|
112
|
+
|
|
113
|
+
// Back up settings, then patch only the statusLine key.
|
|
114
|
+
if (fs.existsSync(SETTINGS)) {
|
|
115
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
116
|
+
fs.copyFileSync(SETTINGS, SETTINGS + '.bak-' + stamp);
|
|
117
|
+
} else {
|
|
118
|
+
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
settings.statusLine = { type: 'command', command: ourCommand(), padding: 0 };
|
|
121
|
+
atomicWrite(SETTINGS, JSON.stringify(settings, null, 2) + '\n');
|
|
122
|
+
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log(' ' + core.renderSegment({ output: 9000, input: 60000, cacheCreation: 0, cacheRead: 0 }, cfg));
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log('aqua-wasted installed. Tier: ' + cfg.tier + ', unit: ' + cfg.unit + '.');
|
|
127
|
+
console.log('Run /statusline in Claude Code or start a new session to see it.');
|
|
128
|
+
console.log('Config: ' + CONFIG);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function uninstall() {
|
|
132
|
+
const settings = readJson(SETTINGS, {});
|
|
133
|
+
if (settings === undefined) {
|
|
134
|
+
console.error('aqua-wasted: could not parse ' + SETTINGS + '. Remove the statusLine block manually.');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
const cfg = readJson(CONFIG, {}) || {};
|
|
138
|
+
const sl = settings.statusLine;
|
|
139
|
+
if (sl && sl.command && isOurCommand(sl.command)) {
|
|
140
|
+
if (cfg.chain) {
|
|
141
|
+
settings.statusLine = { type: 'command', command: cfg.chain };
|
|
142
|
+
console.log('Restored your previous statusLine.');
|
|
143
|
+
} else {
|
|
144
|
+
delete settings.statusLine;
|
|
145
|
+
console.log('Removed the aqua-wasted statusLine.');
|
|
146
|
+
}
|
|
147
|
+
atomicWrite(SETTINGS, JSON.stringify(settings, null, 2) + '\n');
|
|
148
|
+
} else {
|
|
149
|
+
console.log('No aqua-wasted statusLine found in settings. Nothing changed.');
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
fs.rmSync(INSTALL_DIR, { recursive: true, force: true });
|
|
153
|
+
} catch (e) {
|
|
154
|
+
// leave it; not fatal
|
|
155
|
+
}
|
|
156
|
+
console.log('Done. Your settings backups (.bak-*) were kept.');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function status() {
|
|
160
|
+
const cfg = readJson(CONFIG, undefined);
|
|
161
|
+
const settings = readJson(SETTINGS, {});
|
|
162
|
+
const installed =
|
|
163
|
+
settings && settings.statusLine && isOurCommand(settings.statusLine.command || '');
|
|
164
|
+
console.log('aqua-wasted status');
|
|
165
|
+
console.log(' installed: ' + (installed ? 'yes' : 'no'));
|
|
166
|
+
if (cfg) {
|
|
167
|
+
console.log(' tier: ' + cfg.tier);
|
|
168
|
+
console.log(' unit: ' + cfg.unit);
|
|
169
|
+
console.log(' bottles: ' + cfg.bottles);
|
|
170
|
+
console.log(' chained: ' + (cfg.chain ? 'yes' : 'no'));
|
|
171
|
+
}
|
|
172
|
+
console.log(' config: ' + CONFIG);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Walk ~/.claude/projects for every transcript and sum lifetime tokens.
|
|
176
|
+
function lifetimeTotals() {
|
|
177
|
+
const totals = { input: 0, cacheCreation: 0, cacheRead: 0, output: 0 };
|
|
178
|
+
let files = 0;
|
|
179
|
+
function walk(dir) {
|
|
180
|
+
let entries = [];
|
|
181
|
+
try {
|
|
182
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const ent of entries) {
|
|
187
|
+
const full = path.join(dir, ent.name);
|
|
188
|
+
if (ent.isDirectory()) walk(full);
|
|
189
|
+
else if (ent.isFile() && ent.name.endsWith('.jsonl')) {
|
|
190
|
+
files++;
|
|
191
|
+
const t = core.sumTokensFromTranscript(full);
|
|
192
|
+
totals.input += t.input;
|
|
193
|
+
totals.cacheCreation += t.cacheCreation;
|
|
194
|
+
totals.cacheRead += t.cacheRead;
|
|
195
|
+
totals.output += t.output;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
walk(PROJECTS_DIR);
|
|
200
|
+
return { totals: totals, files: files };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function card(argv) {
|
|
204
|
+
const flags = parseFlags(argv);
|
|
205
|
+
const cfg = Object.assign({}, core.DEFAULT_CONFIG, readJson(CONFIG, {}) || {}, {
|
|
206
|
+
tier: flags.tier || (readJson(CONFIG, {}) || {}).tier || core.DEFAULT_CONFIG.tier,
|
|
207
|
+
unit: flags.unit || 'auto',
|
|
208
|
+
});
|
|
209
|
+
const res = lifetimeTotals();
|
|
210
|
+
const ml = core.computeWaterMl(res.totals, cfg.tier);
|
|
211
|
+
const liters = ml / 1000;
|
|
212
|
+
const bottles = Math.round(ml / core.BOTTLE_ML);
|
|
213
|
+
const showers = liters / 70; // a typical shower is roughly 70 liters
|
|
214
|
+
const tokens =
|
|
215
|
+
res.totals.input + res.totals.cacheCreation + res.totals.cacheRead + res.totals.output;
|
|
216
|
+
|
|
217
|
+
const nf = (n, d) => core.formatNumber(n, d, cfg.locale);
|
|
218
|
+
const line = ' ' + '═'.repeat(44);
|
|
219
|
+
console.log('');
|
|
220
|
+
console.log(' \u{1F4A7} aqua-wasted lifetime report');
|
|
221
|
+
console.log(line);
|
|
222
|
+
console.log(' sessions counted : ' + nf(res.files, 0));
|
|
223
|
+
console.log(' tokens burned : ' + nf(tokens, 0));
|
|
224
|
+
console.log(' water wasted : ' + core.formatWater(ml, cfg.unit, cfg.locale));
|
|
225
|
+
console.log(' bottles : ' + nf(bottles, 0) + ' (500 ml each)');
|
|
226
|
+
console.log(' showers : ' + nf(showers, 1));
|
|
227
|
+
console.log(line);
|
|
228
|
+
console.log(' tier: ' + cfg.tier + '. Rough illustrative estimate. See the README.');
|
|
229
|
+
console.log('');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function help() {
|
|
233
|
+
console.log('aqua-wasted: turn your Claude Code token usage into water wasted.');
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log('Usage:');
|
|
236
|
+
console.log(' npx aqua-wasted install [--spicy|--conservative] [--unit=ml|dl|L|auto] [--no-bottles] [--no-chain]');
|
|
237
|
+
console.log(' npx aqua-wasted uninstall');
|
|
238
|
+
console.log(' npx aqua-wasted status');
|
|
239
|
+
console.log(' npx aqua-wasted card [--unit=L]');
|
|
240
|
+
console.log('');
|
|
241
|
+
console.log('Install drops the statusline into ~/.claude/aqua-wasted/ and wires settings.json.');
|
|
242
|
+
console.log('If you already have a statusLine, it is chained, not replaced.');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- dispatch ---------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
const [, , cmd, ...rest] = process.argv;
|
|
248
|
+
switch (cmd) {
|
|
249
|
+
case 'install':
|
|
250
|
+
install(rest);
|
|
251
|
+
break;
|
|
252
|
+
case 'uninstall':
|
|
253
|
+
case 'remove':
|
|
254
|
+
uninstall();
|
|
255
|
+
break;
|
|
256
|
+
case 'status':
|
|
257
|
+
status();
|
|
258
|
+
break;
|
|
259
|
+
case 'card':
|
|
260
|
+
card(rest);
|
|
261
|
+
break;
|
|
262
|
+
case 'help':
|
|
263
|
+
case '--help':
|
|
264
|
+
case '-h':
|
|
265
|
+
case undefined:
|
|
266
|
+
help();
|
|
267
|
+
break;
|
|
268
|
+
default:
|
|
269
|
+
console.error('aqua-wasted: unknown command "' + cmd + '"');
|
|
270
|
+
help();
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aqua-wasted",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Claude Code statusline that turns your token usage into the cooling water your AI session burns.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"aqua-wasted": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"statusline.js",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"card": "node bin/cli.js card",
|
|
20
|
+
"demo": "echo '{\"transcript_path\":\"\",\"context_window\":{\"current_usage\":{\"output_tokens\":9000,\"input_tokens\":60000}}}' | node statusline.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude-code",
|
|
24
|
+
"statusline",
|
|
25
|
+
"tokens",
|
|
26
|
+
"water",
|
|
27
|
+
"sustainability",
|
|
28
|
+
"awareness",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"author": "mehdev67",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/mehdev67/aqua-wasted.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/mehdev67/aqua-wasted#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/mehdev67/aqua-wasted/issues"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {}
|
|
42
|
+
}
|
package/statusline.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// aqua-wasted: a Claude Code statusline that turns your token usage into the
|
|
5
|
+
// cooling water your AI session burns, to make an invisible cost visible.
|
|
6
|
+
//
|
|
7
|
+
// This file is BOTH the runtime (Claude Code runs it as the statusLine command)
|
|
8
|
+
// and a small library the CLI reuses. It has zero dependencies on purpose so it
|
|
9
|
+
// can be copied to ~/.claude/aqua-wasted/ and run standalone forever.
|
|
10
|
+
//
|
|
11
|
+
// Water model (see README for sources and the honest disclaimer):
|
|
12
|
+
// Based on Li et al. "Making AI Less Thirsty" (UC Riverside) and the
|
|
13
|
+
// Washington Post 2024 reporting. Generating tokens costs far more water than
|
|
14
|
+
// reading context, so output and input tokens are weighted separately.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
// ml of cooling water attributed per token, by scariness tier.
|
|
21
|
+
const TIERS = {
|
|
22
|
+
conservative: { output: 0.01, input: 0.002 }, // modern, efficient 2025 models
|
|
23
|
+
headline: { output: 0.03, input: 0.0075 }, // maps 1:1 to the UC Riverside table
|
|
24
|
+
spicy: { output: 0.1, input: 0.025 }, // GPT 4 era, hot region, still under the studied max
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const BOTTLE_ML = 500; // a standard water bottle, for relatable framing
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CONFIG = {
|
|
30
|
+
tier: 'headline',
|
|
31
|
+
unit: 'ml', // auto | ml | dl | L
|
|
32
|
+
bottles: true,
|
|
33
|
+
emoji: '\u{1F4A7}', // droplet
|
|
34
|
+
label: 'Aqua wasted',
|
|
35
|
+
color: 'cyan', // cyan | blue | red | yellow | green | magenta | false
|
|
36
|
+
locale: 'en-US',
|
|
37
|
+
chain: null, // an existing statusLine command to run first, then append ours
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function loadConfig(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const p = path.join(dir, 'config.json');
|
|
43
|
+
if (fs.existsSync(p)) {
|
|
44
|
+
return Object.assign({}, DEFAULT_CONFIG, JSON.parse(fs.readFileSync(p, 'utf8')));
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// fall through to defaults
|
|
48
|
+
}
|
|
49
|
+
return Object.assign({}, DEFAULT_CONFIG);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Sum every token recorded in a session transcript (the cumulative figure that
|
|
53
|
+
// makes the number climb dramatically). Shape matches Claude Code JSONL lines.
|
|
54
|
+
function sumTokensFromTranscript(transcriptPath) {
|
|
55
|
+
const totals = { input: 0, cacheCreation: 0, cacheRead: 0, output: 0 };
|
|
56
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return totals;
|
|
57
|
+
let text = '';
|
|
58
|
+
try {
|
|
59
|
+
text = fs.readFileSync(transcriptPath, 'utf8');
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return totals;
|
|
62
|
+
}
|
|
63
|
+
for (const line of text.split('\n')) {
|
|
64
|
+
if (!line.trim()) continue;
|
|
65
|
+
let obj;
|
|
66
|
+
try {
|
|
67
|
+
obj = JSON.parse(line);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const u = obj && obj.message && obj.message.usage;
|
|
72
|
+
if (!u) continue;
|
|
73
|
+
totals.input += u.input_tokens || 0;
|
|
74
|
+
totals.cacheCreation += u.cache_creation_input_tokens || 0;
|
|
75
|
+
totals.cacheRead += u.cache_read_input_tokens || 0;
|
|
76
|
+
totals.output += u.output_tokens || 0;
|
|
77
|
+
}
|
|
78
|
+
return totals;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback when the transcript is missing: use the live context window numbers
|
|
82
|
+
// Claude Code now passes on stdin.
|
|
83
|
+
function tokensFromPayload(payload) {
|
|
84
|
+
const totals = { input: 0, cacheCreation: 0, cacheRead: 0, output: 0 };
|
|
85
|
+
const cu = payload && payload.context_window && payload.context_window.current_usage;
|
|
86
|
+
if (cu) {
|
|
87
|
+
totals.input = cu.input_tokens || 0;
|
|
88
|
+
totals.cacheCreation = cu.cache_creation_input_tokens || 0;
|
|
89
|
+
totals.cacheRead = cu.cache_read_input_tokens || 0;
|
|
90
|
+
totals.output = cu.output_tokens || 0;
|
|
91
|
+
}
|
|
92
|
+
return totals;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function computeWaterMl(totals, tierName) {
|
|
96
|
+
const tier = TIERS[tierName] || TIERS.headline;
|
|
97
|
+
const inputSide = totals.input + totals.cacheCreation + totals.cacheRead;
|
|
98
|
+
return totals.output * tier.output + inputSide * tier.input;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatNumber(n, decimals, locale) {
|
|
102
|
+
const v = Number(n.toFixed(decimals));
|
|
103
|
+
try {
|
|
104
|
+
return v.toLocaleString(locale || 'en-US');
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return String(v);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatWater(ml, unit, locale) {
|
|
111
|
+
if (unit === 'ml') return formatNumber(Math.round(ml), 0, locale) + ' ml';
|
|
112
|
+
if (unit === 'dl') return formatNumber(ml / 100, 1, locale) + ' dl';
|
|
113
|
+
if (unit === 'L') return formatNumber(ml / 1000, 2, locale) + ' L';
|
|
114
|
+
// auto
|
|
115
|
+
if (ml < 1000) return formatNumber(Math.round(ml), 0, locale) + ' ml';
|
|
116
|
+
const liters = ml / 1000;
|
|
117
|
+
return liters < 10
|
|
118
|
+
? formatNumber(liters, 2, locale) + ' L'
|
|
119
|
+
: formatNumber(liters, 1, locale) + ' L';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ANSI = { cyan: '36', blue: '34', red: '31', yellow: '33', green: '32', magenta: '35' };
|
|
123
|
+
|
|
124
|
+
function colorize(text, color) {
|
|
125
|
+
if (!color || !ANSI[color]) return text;
|
|
126
|
+
return '\x1b[' + ANSI[color] + 'm' + text + '\x1b[0m';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderSegment(totals, cfg) {
|
|
130
|
+
const ml = computeWaterMl(totals, cfg.tier);
|
|
131
|
+
const bottles = Math.round(ml / BOTTLE_ML);
|
|
132
|
+
const bottleWord = bottles === 1 ? 'bottle' : 'bottles';
|
|
133
|
+
const bottleNote =
|
|
134
|
+
cfg.bottles && bottles >= 1
|
|
135
|
+
? ' (≈ ' + formatNumber(bottles, 0, cfg.locale) + ' ' + bottleWord + ')'
|
|
136
|
+
: '';
|
|
137
|
+
const text = cfg.emoji + ' ' + cfg.label + ' ' + formatWater(ml, cfg.unit, cfg.locale) + bottleNote;
|
|
138
|
+
return colorize(text, cfg.color);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Run an existing statusLine command, passing the same stdin through, so we
|
|
142
|
+
// append to it rather than replacing it.
|
|
143
|
+
function runChain(chainCmd, stdinRaw) {
|
|
144
|
+
if (!chainCmd) return '';
|
|
145
|
+
try {
|
|
146
|
+
const res = spawnSync(chainCmd, {
|
|
147
|
+
shell: true,
|
|
148
|
+
input: stdinRaw,
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
timeout: 5000,
|
|
151
|
+
});
|
|
152
|
+
return (res.stdout || '').replace(/\s+$/, '');
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function main() {
|
|
159
|
+
let stdinRaw = '';
|
|
160
|
+
try {
|
|
161
|
+
stdinRaw = fs.readFileSync(0, 'utf8');
|
|
162
|
+
} catch (e) {
|
|
163
|
+
stdinRaw = '';
|
|
164
|
+
}
|
|
165
|
+
let payload = {};
|
|
166
|
+
try {
|
|
167
|
+
payload = JSON.parse(stdinRaw) || {};
|
|
168
|
+
} catch (e) {
|
|
169
|
+
payload = {};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const cfg = loadConfig(__dirname);
|
|
173
|
+
|
|
174
|
+
let totals = sumTokensFromTranscript(payload.transcript_path);
|
|
175
|
+
const sum = totals.input + totals.cacheCreation + totals.cacheRead + totals.output;
|
|
176
|
+
if (sum === 0) totals = tokensFromPayload(payload);
|
|
177
|
+
|
|
178
|
+
const base = runChain(cfg.chain, stdinRaw);
|
|
179
|
+
const segment = renderSegment(totals, cfg);
|
|
180
|
+
process.stdout.write(base ? base + ' ' + segment : segment);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
TIERS,
|
|
185
|
+
BOTTLE_ML,
|
|
186
|
+
DEFAULT_CONFIG,
|
|
187
|
+
loadConfig,
|
|
188
|
+
sumTokensFromTranscript,
|
|
189
|
+
tokensFromPayload,
|
|
190
|
+
computeWaterMl,
|
|
191
|
+
formatWater,
|
|
192
|
+
formatNumber,
|
|
193
|
+
renderSegment,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (require.main === module) main();
|