agent-fuel 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,105 +1,235 @@
1
- import { exec } from 'node:child_process';
1
+ import { exec, execFileSync } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { readFileSync, unlinkSync } from 'node:fs';
4
+ import { TuiScraper } from '../tmux.js';
5
+ import { debug } from '../debug.js';
3
6
  const execAsync = promisify(exec);
4
- export class CodexQuotaAdapter {
5
- budgetLimit;
6
- constructor() {
7
- // Default budget limit of $20.00 for the rolling 5h window (Standard Team/Plus limit)
8
- // Allows dynamic override using environment variable AGENT_FUEL_CODEX_BUDGET
9
- this.budgetLimit = Number(process.env.AGENT_FUEL_CODEX_BUDGET) || 20.0;
7
+ // Used ONLY as a rough fallback estimate when the TUI scrape cannot determine
8
+ // a percentage. This is a GUESS based on local session cost data — not an
9
+ // official Codex quota signal. Override with AGENT_FUEL_CODEX_BUDGET env var.
10
+ const DEFAULT_BUDGET_USD = 20.0;
11
+ const ROLLING_WINDOW_MS = 5 * 60 * 60 * 1000;
12
+ // ── TUI scraper (tmux) ─────────────────────────────────────────────────────
13
+ async function sleep(ms) {
14
+ return new Promise(res => setTimeout(res, ms));
15
+ }
16
+ /**
17
+ * Launches `codex` in a tmux session, pipes all terminal bytes to a temp
18
+ * file, sends /status twice, then reads the file and returns the raw bytes.
19
+ *
20
+ * Why pipe-pane instead of capture-pane:
21
+ * The /status overlay is full-screen and transient — it renders in-place for
22
+ * one frame and re-renders away without entering the tmux scrollback buffer.
23
+ * capture-pane (even with -S history) can never catch it. pipe-pane streams
24
+ * every raw byte to a file so even a 10ms overlay is permanently recorded.
25
+ */
26
+ async function runCodexScrape() {
27
+ const tui = new TuiScraper('codex');
28
+ const pipePath = `/tmp/af-codex-${Date.now()}.log`;
29
+ try {
30
+ tui.start();
31
+ // Stream all pane output to a file from the start
32
+ execFileSync('tmux', ['pipe-pane', '-t', tui.sessionId, `cat >> '${pipePath}'`]);
33
+ debug('codex:scrape', `pipe-pane logging to ${pipePath}`);
34
+ // Wait for TUI ready: current screen (historyLines=0) shows Tip, meaning MCP boot done
35
+ await tui.waitFor(/Tip:/i, 20_000, 0);
36
+ // First /status: panel says "Limits: refresh requested; run /status again shortly"
37
+ tui.send('/status');
38
+ await sleep(2_000);
39
+ // Second /status: has actual 5h/weekly quota data
40
+ tui.send('/status');
41
+ await sleep(4_000);
42
+ const raw = readFileSync(pipePath, 'utf-8');
43
+ debug('codex:scrape', `pipe log size: ${raw.length} bytes`);
44
+ return raw;
45
+ }
46
+ finally {
47
+ tui.kill();
48
+ try {
49
+ unlinkSync(pipePath);
50
+ }
51
+ catch { /* ok */ }
52
+ }
53
+ }
54
+ function stripAnsi(str) {
55
+ // eslint-disable-next-line no-control-regex
56
+ return str
57
+ .replace(/\x1B\[[\x20-\x3f]*[\x40-\x7e]/g, '')
58
+ .replace(/\x1B[^[]/g, '')
59
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
60
+ }
61
+ function parseScrapeOutput(raw) {
62
+ // pipe-pane output contains raw ANSI bytes — strip before pattern matching
63
+ const clean = stripAnsi(raw);
64
+ debug('codex:parse', `raw length: ${raw.length}, cleaned length: ${clean.length}`);
65
+ debug('codex:parse', 'cleaned output', clean);
66
+ debug('codex:parse', 'checking patterns', {
67
+ hasIndividualQuota: /Individual quota reached/i.test(clean),
68
+ hasHeadsUp: /less than \d+%\s+of your 5h limit left/i.test(clean),
69
+ has5hLimit: /5h limit:/i.test(clean),
70
+ hasWeeklyLimit: /Weekly limit:/i.test(clean),
71
+ hasLimits: /Limits:/i.test(clean),
72
+ });
73
+ // "Individual quota reached. Contact your administrator to enable overages. Resets in 4h33m29s."
74
+ if (/Individual quota reached/i.test(clean)) {
75
+ const resetMatch = clean.match(/Resets in\s*((?:\d+h)?(?:\d+m)?(?:\d+s)?)/i);
76
+ let resetIn = null;
77
+ if (resetMatch) {
78
+ const parts = [];
79
+ const hm = resetMatch[1].match(/^(\d+h)?(\d+m)?/);
80
+ if (hm) {
81
+ if (hm[1])
82
+ parts.push(hm[1]);
83
+ if (hm[2])
84
+ parts.push(hm[2]);
85
+ }
86
+ resetIn = parts.length > 0 ? parts.join(' ') : null;
87
+ }
88
+ debug('codex:parse', 'result', { quotaReached: true, resetIn });
89
+ return { quotaReached: true, resetIn, fiveHourRemainingPct: null, fiveHourResetAt: null };
10
90
  }
11
- async fetchSnapshot() {
91
+ // "⚠ Heads up, you have less than X% of your 5h limit left."
92
+ const headsUpMatch = clean.match(/less than (\d+)%\s+of your 5h limit left/i);
93
+ if (headsUpMatch) {
94
+ const ceiling = parseInt(headsUpMatch[1], 10);
95
+ const fiveHourRemainingPct = Math.max(0, ceiling - 1);
96
+ debug('codex:parse', 'result', { source: 'headsUp', ceiling, fiveHourRemainingPct });
97
+ return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt: null };
98
+ }
99
+ // Parse "/status" panel: "5h limit: [...] X% left (resets HH:MM)"
100
+ // Use the LAST match — /status is sent twice and the second response is fresh.
101
+ const allFiveHMatches = [...clean.matchAll(/5h limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/gi)];
102
+ const fiveHMatch = allFiveHMatches.at(-1) ?? null;
103
+ const fiveHourRemainingPct = fiveHMatch ? parseInt(fiveHMatch[1], 10) : null;
104
+ const fiveHourResetAt = fiveHMatch ? fiveHMatch[2].trim() : null;
105
+ debug('codex:parse', 'result', { quotaReached: false, fiveHourRemainingPct, fiveHourResetAt, fiveHMatchRaw: fiveHMatch?.[0] ?? null });
106
+ return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt };
107
+ }
108
+ // ── ccusage fallback estimate ──────────────────────────────────────────────
109
+ async function fetchCcusageEstimate(budgetLimit) {
110
+ const unknown = () => ({
111
+ tool: 'codex',
112
+ remainingPercent: null,
113
+ usedPercent: null,
114
+ resetAt: null,
115
+ source: 'unknown',
116
+ });
117
+ try {
118
+ let stdout;
12
119
  try {
13
- // Execute ccusage to get Codex session data
14
- let stdout;
120
+ ({ stdout } = await execAsync('npx --no-install ccusage codex session --json'));
121
+ }
122
+ catch {
123
+ return unknown();
124
+ }
125
+ debug('codex:ccusage', 'raw stdout', stdout);
126
+ const data = JSON.parse(stdout);
127
+ const sessions = Array.isArray(data?.sessions) ? data.sessions :
128
+ Array.isArray(data?.session) ? data.session :
129
+ Array.isArray(data) ? data : [];
130
+ if (sessions.length === 0) {
131
+ return { tool: 'codex', remainingPercent: 100, usedPercent: 0, resetAt: null, source: 'ccusage' };
132
+ }
133
+ const todayStr = localDateString(new Date());
134
+ const todaySessions = sessions.filter((s) => {
135
+ if (typeof s.lastActivity !== 'string')
136
+ return false;
15
137
  try {
16
- const result = await execAsync('npx --no-install ccusage codex session --json');
17
- stdout = result.stdout;
138
+ return localDateString(new Date(s.lastActivity)) === todayStr;
18
139
  }
19
140
  catch {
20
- throw new Error('ccusage package is not installed or available locally. Please run "npm install -g ccusage" to use this tool.');
141
+ return false;
21
142
  }
22
- const data = JSON.parse(stdout);
23
- const sessions = data && Array.isArray(data.sessions) ? data.sessions : (data && Array.isArray(data.session) ? data.session : data);
24
- if (!sessions || !Array.isArray(sessions)) {
25
- throw new Error('Invalid JSON format returned from ccusage codex session');
143
+ });
144
+ if (todaySessions.length === 0) {
145
+ return { tool: 'codex', remainingPercent: 100, usedPercent: 0, resetAt: null, source: 'ccusage' };
146
+ }
147
+ const totalCost = todaySessions.reduce((acc, s) => acc + (typeof s.costUSD === 'number' ? s.costUSD : 0), 0);
148
+ const usedPct = (totalCost / budgetLimit) * 100;
149
+ const rawRemaining = 100 - usedPct;
150
+ const remainingPercent = usedPct > 0 && rawRemaining > 99 ? 99
151
+ : Math.max(0, Math.min(100, Math.round(rawRemaining)));
152
+ const latestActivity = todaySessions
153
+ .map((s) => new Date(s.lastActivity).getTime())
154
+ .reduce((a, b) => (b > a ? b : a), 0);
155
+ let resetAt = null;
156
+ if (latestActivity > 0) {
157
+ try {
158
+ resetAt = new Date(latestActivity + ROLLING_WINDOW_MS).toLocaleTimeString([], {
159
+ hour: '2-digit', minute: '2-digit',
160
+ });
26
161
  }
27
- // Filter sessions for today's date in local time
28
- const now = new Date();
29
- const year = now.getFullYear();
30
- const month = String(now.getMonth() + 1).padStart(2, '0');
31
- const day = String(now.getDate()).padStart(2, '0');
32
- const todayPrefix = `${year}-${month}-${day}`;
33
- const todaySessions = sessions.filter((s) => {
34
- if (!s.lastActivity)
35
- return false;
36
- try {
37
- const dateObj = new Date(s.lastActivity);
38
- const sYear = dateObj.getFullYear();
39
- const sMonth = String(dateObj.getMonth() + 1).padStart(2, '0');
40
- const sDay = String(dateObj.getDate()).padStart(2, '0');
41
- const sLocalDate = `${sYear}-${sMonth}-${sDay}`;
42
- return sLocalDate === todayPrefix;
43
- }
44
- catch {
45
- return false;
46
- }
47
- });
48
- if (todaySessions.length === 0) {
49
- // No activity today, so 100% fuel remaining
162
+ catch { /* leave null */ }
163
+ }
164
+ debug('codex:ccusage', 'computed', {
165
+ totalCost,
166
+ todaySessionsCount: todaySessions.length,
167
+ budgetLimit,
168
+ usedPct,
169
+ remainingPercent,
170
+ resetAt,
171
+ });
172
+ return {
173
+ tool: 'codex',
174
+ remainingPercent,
175
+ usedPercent: Math.round(usedPct),
176
+ resetAt,
177
+ source: 'ccusage',
178
+ raw: { totalCost, todaySessionsCount: todaySessions.length, isEstimate: true },
179
+ };
180
+ }
181
+ catch {
182
+ return unknown();
183
+ }
184
+ }
185
+ // ── Adapter ────────────────────────────────────────────────────────────────
186
+ export class CodexQuotaAdapter {
187
+ budgetLimit;
188
+ constructor() {
189
+ const override = Number(process.env.AGENT_FUEL_CODEX_BUDGET);
190
+ this.budgetLimit = Number.isFinite(override) && override > 0 ? override : DEFAULT_BUDGET_USD;
191
+ }
192
+ async fetchSnapshots() {
193
+ return [await this._fetch()];
194
+ }
195
+ async _fetch() {
196
+ debug('codex:fetch', 'starting TUI scrape via tmux');
197
+ try {
198
+ const raw = await runCodexScrape();
199
+ const result = parseScrapeOutput(raw);
200
+ if (result.quotaReached) {
201
+ const resetAt = result.resetIn ? `Resets in ${result.resetIn}` : null;
202
+ debug('codex:fetch', 'quota reached → returning 0%');
50
203
  return {
51
204
  tool: 'codex',
52
- remainingPercent: 100,
53
- usedPercent: 0,
54
- resetAt: null,
55
- source: 'ccusage'
205
+ remainingPercent: 0,
206
+ usedPercent: 100,
207
+ resetAt,
208
+ source: 'official-cli',
56
209
  };
57
210
  }
58
- // Sum today's cost
59
- const totalCost = todaySessions.reduce((acc, s) => acc + (s.costUSD || 0.0), 0.0);
60
- const usedPercent = (totalCost / this.budgetLimit) * 100;
61
- // Calculate remaining percentage
62
- let remainingPercent = 100 - usedPercent;
63
- if (usedPercent > 0 && remainingPercent > 99) {
64
- // Micro-interaction: if they burned any credits, show 99% instead of rounding to 100%
65
- remainingPercent = 99;
66
- }
67
- else {
68
- remainingPercent = Math.max(0, Math.min(100, Math.round(remainingPercent)));
69
- }
70
- // Calculate rolling 5-hour reset time based on the most recent session's activity
71
- let resetAt = null;
72
- const sortedSessions = [...todaySessions].sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
73
- const latestSession = sortedSessions[0];
74
- if (latestSession && latestSession.lastActivity) {
75
- try {
76
- const lastActivityDate = new Date(latestSession.lastActivity);
77
- // Roll forward 5 hours for the rolling limit window
78
- const resetDate = new Date(lastActivityDate.getTime() + 5 * 60 * 60 * 1000);
79
- resetAt = resetDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
80
- }
81
- catch {
82
- resetAt = null;
83
- }
211
+ if (result.fiveHourRemainingPct !== null) {
212
+ debug('codex:fetch', `parsed /status ${result.fiveHourRemainingPct}% remaining`);
213
+ return {
214
+ tool: 'codex',
215
+ remainingPercent: result.fiveHourRemainingPct,
216
+ usedPercent: 100 - result.fiveHourRemainingPct,
217
+ resetAt: result.fiveHourResetAt,
218
+ source: 'official-cli',
219
+ };
84
220
  }
85
- return {
86
- tool: 'codex',
87
- remainingPercent,
88
- usedPercent: Math.round(usedPercent),
89
- resetAt,
90
- source: 'ccusage',
91
- raw: { totalCost, todaySessionsCount: todaySessions.length }
92
- };
221
+ debug('codex:fetch', '/status parse failed → falling back to ccusage estimate');
222
+ return fetchCcusageEstimate(this.budgetLimit);
93
223
  }
94
- catch (error) {
95
- return {
96
- tool: 'codex',
97
- remainingPercent: null,
98
- usedPercent: null,
99
- resetAt: null,
100
- source: 'unknown',
101
- raw: error instanceof Error ? error.message : String(error)
102
- };
224
+ catch (err) {
225
+ debug('codex:fetch', 'caught error, falling back to ccusage', String(err));
226
+ return fetchCcusageEstimate(this.budgetLimit);
103
227
  }
104
228
  }
105
229
  }
230
+ function localDateString(date) {
231
+ const y = date.getFullYear();
232
+ const m = String(date.getMonth() + 1).padStart(2, '0');
233
+ const d = String(date.getDate()).padStart(2, '0');
234
+ return `${y}-${m}-${d}`;
235
+ }
@@ -1,11 +1,12 @@
1
1
  export interface UsageSnapshot {
2
- tool: 'codex' | 'claude-code' | 'agy';
2
+ tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other';
3
3
  remainingPercent: number | null;
4
4
  usedPercent?: number | null;
5
5
  resetAt?: string | null;
6
- source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'unknown';
6
+ source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'cache' | 'unknown';
7
7
  raw?: unknown;
8
8
  }
9
9
  export interface QuotaAdapter {
10
- fetchSnapshot(): Promise<UsageSnapshot>;
10
+ /** Returns one or more snapshots (adapters that produce multiple rows return an array). */
11
+ fetchSnapshots(): Promise<UsageSnapshot[]>;
11
12
  }
@@ -0,0 +1,4 @@
1
+ declare const enabled: boolean;
2
+ declare const logFile: string | null;
3
+ export declare function debug(tag: string, message: string, data?: unknown): void;
4
+ export { enabled as debugEnabled, logFile as debugLogFile };
package/dist/debug.js ADDED
@@ -0,0 +1,18 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const enabled = process.argv.includes('--debug');
4
+ const logFile = enabled
5
+ ? (() => {
6
+ const dir = join(process.cwd(), '.logs');
7
+ mkdirSync(dir, { recursive: true });
8
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
9
+ return join(dir, `debug-${ts}.log`);
10
+ })()
11
+ : null;
12
+ export function debug(tag, message, data) {
13
+ if (!logFile)
14
+ return;
15
+ const line = `[${new Date().toISOString()}] [${tag}] ${message}${data !== undefined ? '\n' + JSON.stringify(data, null, 2) : ''}\n`;
16
+ appendFileSync(logFile, line);
17
+ }
18
+ export { enabled as debugEnabled, logFile as debugLogFile };
package/dist/index.js CHANGED
@@ -1,29 +1,81 @@
1
1
  #!/usr/bin/env node
2
+ import { debugEnabled, debugLogFile } from './debug.js';
2
3
  import { ClaudeQuotaAdapter } from './adapters/claude.js';
3
4
  import { CodexQuotaAdapter } from './adapters/codex.js';
4
5
  import { AgyQuotaAdapter } from './adapters/agy.js';
5
- import { renderDashboard } from './render.js';
6
+ import { printHeader, printFooter, formatRow, getDisplayName } from './render.js';
7
+ // Fixed display order — never changes regardless of which adapter resolves first
8
+ const SLOT_ORDER = ['claude-code', 'codex', 'agy-gemini', 'agy-other'];
9
+ const BOLD = '\x1b[1m';
10
+ const DIM = '\x1b[2m';
11
+ const R = '\x1b[0m';
12
+ const GRAY = '\x1b[90m';
13
+ const isTTY = Boolean(process.stdout.isTTY);
14
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
15
+ let spinnerTick = 0;
16
+ function spinnerLine(tool) {
17
+ const frame = SPINNER[spinnerTick % SPINNER.length];
18
+ return `${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${DIM}${GRAY}${frame} loading...${R}\x1b[K`;
19
+ }
20
+ // In TTY mode: restore cursor to saved position and repaint all slots.
21
+ // In pipe mode: emit each newly-resolved line exactly once (tracked via emitted set).
22
+ function redraw(slots, emitted) {
23
+ if (!isTTY) {
24
+ for (const tool of SLOT_ORDER) {
25
+ const line = slots.get(tool);
26
+ if (line != null && !emitted.has(tool)) {
27
+ process.stdout.write(line + '\n');
28
+ emitted.add(tool);
29
+ }
30
+ }
31
+ return;
32
+ }
33
+ process.stdout.write('\x1b8'); // DEC restore-cursor — teleports back to saved position
34
+ for (const tool of SLOT_ORDER) {
35
+ process.stdout.write('\x1b[2K\r');
36
+ const line = slots.get(tool);
37
+ process.stdout.write((line != null ? line + '\x1b[K' : spinnerLine(tool)) + '\n');
38
+ }
39
+ }
6
40
  async function main() {
7
41
  const claudeAdapter = new ClaudeQuotaAdapter();
8
42
  const codexAdapter = new CodexQuotaAdapter();
9
43
  const agyAdapter = new AgyQuotaAdapter();
10
- try {
11
- // Run all adapters concurrently to minimize startup latency
12
- const [claudeSnap, codexSnap, agySnap] = await Promise.all([
13
- claudeAdapter.fetchSnapshot(),
14
- codexAdapter.fetchSnapshot(),
15
- agyAdapter.fetchSnapshot()
16
- ]);
17
- // Render the beautiful 3-bar ASCII progress dashboard
18
- renderDashboard([
19
- codexSnap,
20
- claudeSnap,
21
- agySnap
22
- ]);
44
+ if (debugEnabled)
45
+ process.stderr.write(`\x1b[2m[debug] logging to ${debugLogFile}\x1b[0m\n`);
46
+ printHeader();
47
+ // Save cursor before the placeholder rows so redraw() can teleport back and overwrite them
48
+ const slots = new Map(SLOT_ORDER.map(t => [t, null]));
49
+ const emitted = new Set(); // pipe-mode: tracks which lines have been printed
50
+ if (isTTY)
51
+ process.stdout.write('\x1b7'); // DEC save-cursor
52
+ for (const tool of SLOT_ORDER) {
53
+ process.stdout.write(spinnerLine(tool) + '\n');
23
54
  }
24
- catch (error) {
25
- console.error('\x1b[31mFatal error orchestrating Agent Fuel CLI:\x1b[0m', error);
26
- process.exit(1);
55
+ // Animate spinner at 80ms while any slot is still loading
56
+ const spinnerTimer = isTTY
57
+ ? setInterval(() => { spinnerTick++; redraw(slots, emitted); }, 80)
58
+ : null;
59
+ // Each adapter fills its slot(s) and triggers a redraw; order is always fixed
60
+ function fill(snaps) {
61
+ for (const snap of snaps) {
62
+ if (SLOT_ORDER.includes(snap.tool)) {
63
+ slots.set(snap.tool, formatRow(snap));
64
+ }
65
+ }
66
+ redraw(slots, emitted);
27
67
  }
68
+ await Promise.allSettled([
69
+ claudeAdapter.fetchSnapshots().then(fill),
70
+ codexAdapter.fetchSnapshots().then(fill),
71
+ agyAdapter.fetchSnapshots().then(fill),
72
+ ]);
73
+ if (spinnerTimer)
74
+ clearInterval(spinnerTimer);
75
+ redraw(slots, emitted); // final clean repaint with all data
76
+ printFooter();
28
77
  }
29
- main();
78
+ main().catch((error) => {
79
+ console.error('\x1b[31mFatal error orchestrating Agent Fuel CLI:\x1b[0m', error);
80
+ process.exit(1);
81
+ });
package/dist/render.d.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  import { UsageSnapshot } from './adapters/index.js';
2
+ export declare function getDisplayName(tool: string): string;
3
+ export declare function formatRow(snap: UsageSnapshot): string;
4
+ export declare function printHeader(): void;
5
+ export declare function printRow(snap: UsageSnapshot): void;
6
+ export declare function printFooter(): void;
7
+ export declare const LOADING_LINE = "\u001B[2m\u001B[90mloading...\u001B[0m";
8
+ /** Convenience wrapper — renders a full static dashboard in one call. */
2
9
  export declare function renderDashboard(snapshots: UsageSnapshot[]): void;
package/dist/render.js CHANGED
@@ -1,74 +1,111 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- export function renderDashboard(snapshots) {
5
- const reset = '\x1b[0m';
6
- const bold = '\x1b[1m';
7
- const dim = '\x1b[2m';
8
- const cyan = '\x1b[36m';
9
- const green = '\x1b[32m';
10
- const yellow = '\x1b[33m';
11
- const red = '\x1b[31m';
12
- const gray = '\x1b[90m';
13
- // Load version from package.json dynamically
14
- let versionStr = '';
4
+ // ── Constants ──────────────────────────────────────────────────────────────
5
+ const BLOCK_CHAR = '';
6
+ const SHADE_CHAR = '';
7
+ const BAR_WIDTH = 30;
8
+ // ── ANSI colour helpers ────────────────────────────────────────────────────
9
+ const R = '\x1b[0m';
10
+ const BOLD = '\x1b[1m';
11
+ const DIM = '\x1b[2m';
12
+ const CYAN = '\x1b[36m';
13
+ const GREEN = '\x1b[32m';
14
+ const YELLOW = '\x1b[33m';
15
+ const RED = '\x1b[31m';
16
+ const GRAY = '\x1b[90m';
17
+ // ── Helpers ────────────────────────────────────────────────────────────────
18
+ export function getDisplayName(tool) {
19
+ switch (tool) {
20
+ case 'codex': return 'Codex';
21
+ case 'claude-code': return 'Claude Code';
22
+ case 'agy-gemini': return 'AGY Gemini';
23
+ case 'agy-other': return 'AGY Other';
24
+ default: return tool;
25
+ }
26
+ }
27
+ function pickColour(remaining) {
28
+ if (remaining < 20)
29
+ return RED;
30
+ if (remaining < 50)
31
+ return YELLOW;
32
+ return GREEN;
33
+ }
34
+ function loadVersion() {
15
35
  try {
16
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
- const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
18
- versionStr = ` v${packageJson.version}`;
36
+ const dir = path.dirname(fileURLToPath(import.meta.url));
37
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, '../package.json'), 'utf8'));
38
+ return ` v${pkg.version}`;
19
39
  }
20
40
  catch {
21
- // Fallback if reading package.json fails
41
+ return '';
22
42
  }
23
- console.log(`\n${bold}${cyan}⚡️ Agent Fuel - CLI Quota Monitor${reset}\n`);
24
- for (const snap of snapshots) {
25
- const displayName = getDisplayName(snap.tool);
26
- const remaining = snap.remainingPercent;
27
- const width = 30;
28
- let barStr = '';
29
- let percentStr = '';
30
- if (remaining === null || remaining === undefined) {
31
- // Unknown/Unconfigured quota
32
- barStr = `${gray}${'░'.repeat(width)}${reset}`;
33
- percentStr = `${gray}unknown${reset}`;
34
- }
35
- else {
36
- const filled = Math.max(0, Math.min(width, Math.round((remaining * width) / 100)));
37
- const empty = width - filled;
38
- // Color scheme based on remaining percentage
39
- let color = green;
40
- if (remaining < 20) {
41
- color = red;
42
- }
43
- else if (remaining < 50) {
44
- color = yellow;
45
- }
46
- const blockChar = '█';
47
- const shadeChar = '░';
48
- barStr = `${color}${blockChar.repeat(filled)}${reset}${gray}${shadeChar.repeat(empty)}${reset}`;
49
- percentStr = `${bold}${color}${remaining.toString().padStart(3)}% remaining${reset}`;
50
- }
51
- // Add metadata/reset times if available
52
- let detailStr = '';
53
- if (snap.resetAt) {
54
- detailStr = ` ${dim}${gray}(resets ${snap.resetAt})${reset}`;
55
- }
56
- if (snap.tool === 'agy' && snap.raw && typeof snap.raw === 'object' && 'activeModel' in snap.raw) {
57
- detailStr += ` ${dim}${gray}[${snap.raw.activeModel}]${reset}`;
58
- }
59
- console.log(`${bold}${displayName.padEnd(12)}${reset} [${barStr}] ${percentStr}${detailStr}`);
43
+ }
44
+ function formatResetAt(resetAt) {
45
+ if (resetAt.toLowerCase().includes('available')) {
46
+ return `${DIM}${GREEN}✓ quota available${R}`;
47
+ }
48
+ const codexMatch = resetAt.match(/^Resets in\s*(.+)/i);
49
+ if (codexMatch) {
50
+ return `${DIM}${GRAY}(resets in ${codexMatch[1]})${R}`;
51
+ }
52
+ const agyMatch = resetAt.match(/^Refreshes in\s*(.+)/i);
53
+ if (agyMatch) {
54
+ return `${DIM}${GRAY}(resets in ${agyMatch[1]})${R}`;
60
55
  }
61
- console.log(`\n${dim}${gray}agent-fuel${versionStr}${reset}\n`);
56
+ return `${DIM}${GRAY}(resets ${resetAt})${R}`;
62
57
  }
63
- function getDisplayName(tool) {
64
- switch (tool) {
65
- case 'codex':
66
- return 'Codex';
67
- case 'claude-code':
68
- return 'Claude Code';
69
- case 'agy':
70
- return 'AGY';
71
- default:
72
- return tool;
58
+ function isEstimate(snap) {
59
+ return snap.source === 'ccusage';
60
+ }
61
+ // ── Core format (returns string, no newline) ───────────────────────────────
62
+ export function formatRow(snap) {
63
+ const displayName = getDisplayName(snap.tool);
64
+ const remaining = snap.remainingPercent;
65
+ let barStr;
66
+ let percentStr;
67
+ if (remaining === null) {
68
+ barStr = `${GRAY}${SHADE_CHAR.repeat(BAR_WIDTH)}${R}`;
69
+ percentStr = `${GRAY}unknown${R}`;
70
+ }
71
+ else {
72
+ const colour = pickColour(remaining);
73
+ const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((remaining * BAR_WIDTH) / 100)));
74
+ const empty = BAR_WIDTH - filled;
75
+ barStr = `${colour}${BLOCK_CHAR.repeat(filled)}${R}${GRAY}${SHADE_CHAR.repeat(empty)}${R}`;
76
+ percentStr = `${BOLD}${colour}${remaining.toString().padStart(3)}% remaining${R}`;
77
+ }
78
+ const parts = [];
79
+ if (snap.resetAt)
80
+ parts.push(formatResetAt(snap.resetAt));
81
+ if ((snap.tool === 'agy-gemini' || snap.tool === 'agy-other') &&
82
+ snap.raw && typeof snap.raw === 'object') {
83
+ const label = snap.raw.matchedModel;
84
+ if (typeof label === 'string' && label) {
85
+ parts.push(`${DIM}${GRAY}[${label}]${R}`);
86
+ }
87
+ }
88
+ if (isEstimate(snap)) {
89
+ parts.push(`${DIM}${GRAY}[~est]${R}`);
73
90
  }
91
+ const detailStr = parts.length > 0 ? ` ${parts.join(' ')}` : '';
92
+ return `${BOLD}${displayName.padEnd(13)}${R} [${barStr}] ${percentStr}${detailStr}`;
93
+ }
94
+ // ── Public render functions ────────────────────────────────────────────────
95
+ export function printHeader() {
96
+ console.log(`\n${BOLD}${CYAN}⚡️ Agent Fuel - CLI Quota Monitor${R}\n`);
97
+ }
98
+ export function printRow(snap) {
99
+ process.stdout.write(formatRow(snap) + '\n');
100
+ }
101
+ export function printFooter() {
102
+ console.log(`\n${DIM}${GRAY}agent-fuel${loadVersion()}${R}\n`);
103
+ }
104
+ export const LOADING_LINE = `${DIM}${GRAY}loading...${R}`;
105
+ /** Convenience wrapper — renders a full static dashboard in one call. */
106
+ export function renderDashboard(snapshots) {
107
+ printHeader();
108
+ for (const snap of snapshots)
109
+ printRow(snap);
110
+ printFooter();
74
111
  }