agent-fuel 0.1.0 → 0.3.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 CHANGED
@@ -4,82 +4,125 @@ A sleek, unified CLI dashboard to monitor your AI coding assistant quotas, credi
4
4
 
5
5
  ---
6
6
 
7
- ## 🚀 Quick Start
7
+ ## 🚀 Installation & Running
8
8
 
9
- Get **Agent Fuel** installed, globally linked, and running on your system with a single one-liner command:
9
+ Install **Agent Fuel** globally:
10
10
 
11
11
  ```bash
12
- npm install && npm run build && npm link && agent-fuel
12
+ npm install -g agent-fuel
13
13
  ```
14
14
 
15
- Once globally linked, you can run the dashboard at any time from **any directory** on your machine by simply typing:
15
+ Then run from any directory:
16
16
 
17
17
  ```bash
18
18
  agent-fuel
19
19
  ```
20
20
 
21
+ ### Development Setup
22
+
23
+ ```bash
24
+ git clone https://github.com/jperod/agent-fuel.git
25
+ cd agent-fuel
26
+ npm install
27
+ npm run build
28
+ npm link
29
+ ```
30
+
21
31
  ---
22
32
 
23
33
  ## 💡 The Motivation
24
34
 
25
- AI coding assistants are now integral to developer workflows. Tools like **Claude Code**, **Codex CLI**, and **AGY (Google Antigravity CLI)** supercharge productivity but operate under tight, separate quota bounds. Whether it is a daily dollar limit, token ceilings, or monthly credits, developers are forced to jump through interactive prompts or scrape configuration screens just to answer a simple question:
35
+ AI coding assistants are now integral to developer workflows. Tools like **Claude Code**, **Codex CLI**, and **AGY (Google Antigravity CLI)** supercharge productivity but operate under tight, separate quota bounds. Developers are forced to jump through interactive prompts or scrape configuration screens just to answer:
26
36
 
27
37
  > **"How much agent fuel do I have left before starting this massive refactor?"**
28
38
 
29
- Because each CLI exposes quota information differently (some via strict JSON, others through human-readable prompts, and some in local state files), there is no centralized way to monitor your resource consumption.
30
-
31
- **Agent Fuel** solves this by acting as a lightweight, adapter-based abstraction layer that normalizes all coding agent quotas into a single metric: **Percent Remaining**.
39
+ **Agent Fuel** solves this by acting as a lightweight, adapter-based abstraction layer that normalises all coding agent quotas into a single metric: **Percent Remaining**.
32
40
 
33
41
  ---
34
42
 
35
- ## 🎯 The Idea
43
+ ## 🎯 How It Works
36
44
 
37
- `agent-fuel` is a tiny, modern local CLI built with TypeScript that:
38
- 1. **Dispatches Adapters**: Queries each configured AI coding tool (Claude Code, Codex, AGY) using native CLI calls, helper utilities (like `ccusage`), or local config parsing.
39
- 2. **Normalizes Quota Models**: Standardizes diverse limits into a uniform percentage score (`0` to `100%`).
40
- 3. **Renders an Elegant CLI Dashboard**: Displays a high-fidelity 3-bar ASCII progress dashboard directly in your terminal.
45
+ `agent-fuel` is a tiny modern CLI built with TypeScript that:
41
46
 
42
- ### Proposed Architecture
47
+ 1. **Dispatches Adapters concurrently** — all adapters run in parallel and each row is printed the moment its adapter resolves; you never wait for the slowest tool.
48
+ 2. **Normalises Quota Models** — standardises diverse limits into a uniform `0–100%` score.
49
+ 3. **Scrapes TUI output directly** — Codex and AGY quotas are read by spawning the real CLIs via `expect` and parsing terminal output, so the numbers match what the tools themselves show.
50
+ 4. **Caches AGY results** — AGY quota is cached for 5 minutes so repeated runs are instant (~1s).
51
+ 5. **Renders a clean dashboard** — colour-coded bars with reset times directly in your terminal.
52
+
53
+ ### Project Architecture
43
54
 
44
55
  ```text
45
56
  agent-fuel/
46
57
  ├── src/
47
- │ ├── index.ts # CLI entry point
48
- │ ├── render.ts # Beautiful 3-bar dashboard renderer
58
+ │ ├── index.ts # CLI entry point — runs all adapters concurrently
59
+ │ ├── render.ts # Colour-coded bar dashboard renderer
49
60
  │ └── adapters/
50
- │ ├── claude.ts # Adapter for Claude Code (ccusage/native)
51
- │ ├── codex.ts # Adapter for Codex (local session/status)
52
- │ ├── agy.ts # Adapter for AGY (Antigravity config parser)
53
- │ └── ccusage.ts # Shared JSON parser helper
61
+ │ ├── index.ts # Shared UsageSnapshot type & QuotaAdapter interface
62
+ │ ├── claude.ts # Claude Code (via ccusage blocks)
63
+ │ ├── codex.ts # Codex CLI (expect TUI scrape; ccusage as fallback estimate)
64
+ │ └── agy.ts # AGY split into Gemini + Other buckets
54
65
  ├── package.json
55
66
  └── README.md
56
67
  ```
57
68
 
58
- ### High-Fidelity API Type Shape
69
+ ### Type Shape
59
70
 
60
71
  ```typescript
61
72
  type UsageSnapshot = {
62
- tool: 'codex' | 'claude-code' | 'agy';
63
- remainingPercent: number | null; // Unified 0-100 scale
73
+ tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other';
74
+ remainingPercent: number | null; // Unified 0100 scale
64
75
  usedPercent?: number | null;
65
76
  resetAt?: string | null;
66
- source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'unknown';
77
+ source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'cache' | 'unknown';
67
78
  raw?: unknown;
68
79
  };
69
80
  ```
70
81
 
71
82
  ---
72
83
 
73
- ## 📊 Terminal Dashboard Preview
74
-
75
- Running `agent-fuel` will immediately output a clean, colored visual summary of your current agent capacity:
84
+ ## 📊 Terminal Dashboard
76
85
 
77
- ```text
86
+ ```
78
87
  ⚡️ Agent Fuel - CLI Quota Monitor
79
88
 
80
- Codex [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] unknown
81
- Claude Code [██████████████████████████░░░░] 86% remaining (resets 01:00 PM)
82
- AGY [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] unknown
89
+ Claude Code [███████████████████░░░░░░░░░░░] 64% remaining (resets 01:00 PM)
90
+ Codex [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0% remaining (resets in 4h 33m)
91
+ AGY Gemini [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0% remaining (resets in 3h 16m) [Gemini 3.5 Flash (Medium)]
92
+ AGY Other [██████████████████░░░░░░░░░░░░] 60% remaining (resets in 4h 47m) [Claude Sonnet 4.6 (Thinking)]
93
+
94
+ agent-fuel v0.3.0
83
95
  ```
84
96
 
97
+ Rows appear as each adapter resolves — Claude Code (instant) prints first, Codex and AGY follow as their TUI scrapes complete.
98
+
99
+ **AGY Gemini** shows the worst-case remaining across all `Gemini *` model tiers.
100
+ **AGY Other** shows the worst-case across Claude and other non-Gemini models.
101
+ **Codex** row tagged `[~est]` when quota has not been reached and the percentage is estimated from local session cost data (see fallback note below).
102
+
103
+ ---
104
+
105
+ ## ⚙️ Environment Overrides
106
+
107
+ | Variable | Default | Description |
108
+ |---|---|---|
109
+ | `AGENT_FUEL_CLAUDE_BUDGET` | `20.0` | Claude Code rolling budget in USD |
110
+ | `AGENT_FUEL_CODEX_BUDGET` | `20.0` | **Fallback estimate only** — Codex rolling budget in USD |
111
+
112
+ > **Note on `AGENT_FUEL_CODEX_BUDGET`:** Codex quota is read directly from the Codex TUI via `expect` scraping. This variable is only used as a rough fallback estimate (shown as `[~est]`) when the TUI reports no quota warning and a percentage cannot be determined. It is a guess based on local session cost data — not an official Codex quota signal. The TUI scrape is always preferred.
113
+
114
+ ---
115
+
116
+ ## 📦 Changelog
117
+
118
+ ### v0.3.0
119
+ - **Codex TUI scrape**: replaced inaccurate `ccusage` cost estimate with an `expect` wrapper that reads the real Codex quota warning (`"Individual quota reached. Resets in Xh Ym"`) — same pattern as AGY. `ccusage` kept as a labelled `[~est]` fallback when quota has not yet been exhausted.
120
+ - **Streaming render with fixed order**: placeholder rows print immediately; each bar overwrites in-place as its adapter resolves. Row order is always `Claude Code → Codex → AGY Gemini → AGY Other`.
121
+ - **AGY split view**: Gemini and non-Gemini (Claude, etc.) quota buckets shown as separate rows
122
+ - **5-minute disk cache** for AGY quota — repeated runs complete in ~1s instead of ~20s
123
+ - Output size cap, typed `any` removal, env validation hardening
85
124
 
125
+ ### v0.2.x
126
+ - AGY quota now scraped live from `agy /usage` panel via `expect` wrapper (zero token cost)
127
+ - Claude Code budget corrected to $20 rolling limit
128
+ - Replaced token-consuming `claude -p` calls with offline `ccusage` scraping
@@ -1,6 +1,4 @@
1
1
  import { QuotaAdapter, UsageSnapshot } from './index.js';
2
2
  export declare class AgyQuotaAdapter implements QuotaAdapter {
3
- private configDir;
4
- constructor();
5
- fetchSnapshot(): Promise<UsageSnapshot>;
3
+ fetchSnapshots(): Promise<UsageSnapshot[]>;
6
4
  }
@@ -1,108 +1,199 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- export class AgyQuotaAdapter {
5
- configDir;
6
- constructor() {
7
- // Default to the standard AGY CLI config directory
8
- this.configDir = path.join(os.homedir(), '.gemini/antigravity-cli');
9
- }
10
- async fetchSnapshot() {
11
- try {
12
- const settingsPath = path.join(this.configDir, 'settings.json');
13
- const historyPath = path.join(this.configDir, 'history.jsonl');
14
- let activeModel = 'Gemini 3.5 Flash';
15
- // 1. Read active model from settings.json if it exists
16
- try {
17
- const settingsContent = await fs.readFile(settingsPath, 'utf-8');
18
- const settings = JSON.parse(settingsContent);
19
- if (settings && settings.model) {
20
- activeModel = settings.model;
21
- }
22
- }
23
- catch {
24
- // Fallback to default model name if reading or parsing settings failed
4
+ import { spawn } from 'node:child_process';
5
+ const CACHE_PATH = path.join(os.homedir(), '.gemini/antigravity-cli/.agent-fuel-quota-cache.json');
6
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
7
+ // ── ANSI / terminal helpers ────────────────────────────────────────────────
8
+ function stripAnsi(str) {
9
+ // eslint-disable-next-line no-control-regex
10
+ return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B[^[]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
11
+ }
12
+ // ── Scraping ───────────────────────────────────────────────────────────────
13
+ /**
14
+ * Spawns `agy` via `expect`, opens the `/usage` panel, waits for the
15
+ * Model Quota list to render, then exits.
16
+ */
17
+ function runAgyUsage() {
18
+ return new Promise((resolve) => {
19
+ const expectScript = [
20
+ 'set timeout 20',
21
+ 'spawn agy',
22
+ 'expect -re "for shortcuts"',
23
+ 'send "/usage\\r"',
24
+ 'expect -re "Model Quota"',
25
+ 'after 800',
26
+ 'send "\\x03"',
27
+ 'expect eof',
28
+ ].join('\n');
29
+ // Cap output to avoid unbounded memory growth if agy misbehaves
30
+ const MAX_OUTPUT_BYTES = 64 * 1024; // 64 KB — far more than the quota panel needs
31
+ let output = '';
32
+ // Spread process.env so `expect` can locate `agy` via PATH and the
33
+ // keyring daemon can be reached via the existing session environment.
34
+ const child = spawn('expect', ['-c', expectScript], {
35
+ env: { ...process.env },
36
+ stdio: ['ignore', 'pipe', 'pipe'],
37
+ });
38
+ const append = (chunk) => {
39
+ if (output.length < MAX_OUTPUT_BYTES) {
40
+ output += chunk.toString();
25
41
  }
26
- // 2. Read history.jsonl to detect active prompts today
27
- let todayPromptsCount = 0;
28
- let latestPromptTimestamp = null;
29
- // Construct local todayPrefix in YYYY-MM-DD format (timezone aware)
30
- const now = new Date();
31
- const year = now.getFullYear();
32
- const month = String(now.getMonth() + 1).padStart(2, '0');
33
- const day = String(now.getDate()).padStart(2, '0');
34
- const todayPrefix = `${year}-${month}-${day}`;
35
- try {
36
- const historyContent = await fs.readFile(historyPath, 'utf-8');
37
- const historyLines = historyContent.trim().split('\n');
38
- for (const line of historyLines) {
39
- if (!line.trim())
40
- continue;
41
- const entry = JSON.parse(line);
42
- if (entry && entry.timestamp) {
43
- // Get local date YYYY-MM-DD for the entry's timestamp
44
- const entryDateObj = new Date(entry.timestamp);
45
- const eYear = entryDateObj.getFullYear();
46
- const eMonth = String(entryDateObj.getMonth() + 1).padStart(2, '0');
47
- const eDay = String(entryDateObj.getDate()).padStart(2, '0');
48
- const entryDate = `${eYear}-${eMonth}-${eDay}`;
49
- if (entryDate === todayPrefix) {
50
- todayPromptsCount++;
51
- if (!latestPromptTimestamp || entry.timestamp > latestPromptTimestamp) {
52
- latestPromptTimestamp = entry.timestamp;
53
- }
54
- }
55
- }
42
+ };
43
+ child.stdout.on('data', append);
44
+ child.stderr.on('data', append);
45
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(output); }, 25_000);
46
+ child.on('close', () => { clearTimeout(timer); resolve(output); });
47
+ });
48
+ }
49
+ // ── Parsing ────────────────────────────────────────────────────────────────
50
+ /**
51
+ * Parse the Model Quota panel into an array of entries.
52
+ *
53
+ * Panel format (after ANSI strip):
54
+ *
55
+ * └ Model Quota
56
+ *
57
+ * Gemini 3.5 Flash (High)
58
+ * ░░░░░░░░░░░ ... 20%
59
+ * Refreshes in 3h 28m ← or "80% remaining · Refreshes in …"
60
+ *
61
+ * Claude Sonnet 4.6 (Thinking)
62
+ * ███████████ ... 100%
63
+ * Quota available
64
+ */
65
+ function parseQuotaPanel(raw) {
66
+ const clean = stripAnsi(raw);
67
+ const lines = clean.split(/\r?\n/);
68
+ const results = [];
69
+ const headerIdx = lines.findIndex(l => l.includes('Model Quota'));
70
+ if (headerIdx === -1)
71
+ return results;
72
+ const panelLines = lines.slice(headerIdx + 1);
73
+ let i = 0;
74
+ while (i < panelLines.length) {
75
+ const line = panelLines[i].trim();
76
+ const isModelName = line.length > 0 &&
77
+ !line.startsWith('░') && !line.startsWith('█') &&
78
+ !line.startsWith('↑') && !line.startsWith('(') &&
79
+ !line.startsWith('┘') && !line.startsWith('└') &&
80
+ !line.startsWith('?') && !line.startsWith('esc') &&
81
+ !/^\d+%/.test(line) &&
82
+ !line.includes('Refreshes') && !line.includes('Quota available') &&
83
+ !line.includes('──');
84
+ if (isModelName) {
85
+ let barLine = null;
86
+ let refreshLine = null;
87
+ let j = i + 1;
88
+ while (j < panelLines.length) {
89
+ const candidate = panelLines[j].trim();
90
+ if (candidate.length === 0) {
91
+ j++;
92
+ continue;
56
93
  }
57
- }
58
- catch {
59
- // Fallback if reading or parsing history failed (e.g. file doesn't exist)
60
- }
61
- // 3. Calculate remaining percent based on active usage and model tier
62
- // Support dynamic overrides using AGENT_FUEL_AGY_PERCENT environment variable
63
- let remainingPercent = 100;
64
- const isProModel = activeModel.toLowerCase().includes('pro');
65
- const limit = isProModel ? 3 : 5; // Pro models have a tighter limit of 3, Flash has 5
66
- const costPerPrompt = 100 / limit;
67
- const calculatedPercent = Math.max(0, Math.round(100 - (todayPromptsCount * costPerPrompt)));
68
- if (process.env.AGENT_FUEL_AGY_PERCENT) {
69
- const envVal = Number(process.env.AGENT_FUEL_AGY_PERCENT);
70
- remainingPercent = !isNaN(envVal) ? Math.max(0, Math.min(100, envVal)) : calculatedPercent;
71
- }
72
- else {
73
- remainingPercent = calculatedPercent;
74
- }
75
- // 4. Calculate rolling reset time (5 hours rolling or resets in 4h 37m from latest prompt, giving ~01:30 PM resets)
76
- let resetAt = null;
77
- if (latestPromptTimestamp) {
78
- try {
79
- const lastActivityDate = new Date(latestPromptTimestamp);
80
- // Roll forward 5 hours (refreshes in ~4h 37m from active run)
81
- const resetDate = new Date(lastActivityDate.getTime() + 5 * 60 * 60 * 1000);
82
- resetAt = resetDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
94
+ // Progress bar line: has block chars OR starts with a digit%
95
+ if (barLine === null && (candidate.includes('░') || candidate.includes('█') || /^\d+%/.test(candidate))) {
96
+ barLine = candidate;
97
+ j++;
98
+ continue;
83
99
  }
84
- catch {
85
- resetAt = null;
100
+ // Refresh / availability line
101
+ if (barLine !== null && (candidate.includes('Refreshes') || candidate.includes('Quota available'))) {
102
+ // Strip any leading "NN% remaining · " prefix
103
+ const m = candidate.match(/(Refreshes in [^\r\n]+|Quota available)/);
104
+ refreshLine = m ? m[1] : candidate;
105
+ j++;
86
106
  }
107
+ break;
108
+ }
109
+ if (barLine !== null) {
110
+ // Percentage is always at the END of the bar line: "░░░ ... 20%" or "20% remaining · …"
111
+ const percentMatch = barLine.match(/(\d+)%/);
112
+ if (percentMatch) {
113
+ results.push({ model: line, percent: parseInt(percentMatch[1], 10), refreshLine });
114
+ }
115
+ }
116
+ i = j;
117
+ }
118
+ else {
119
+ i++;
120
+ }
121
+ }
122
+ return results;
123
+ }
124
+ // ── Cache helpers ──────────────────────────────────────────────────────────
125
+ async function readCache() {
126
+ try {
127
+ return JSON.parse(await fs.readFile(CACHE_PATH, 'utf-8'));
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ async function writeCache(entries) {
134
+ try {
135
+ await fs.writeFile(CACHE_PATH, JSON.stringify({ fetchedAt: Date.now(), entries }), 'utf-8');
136
+ }
137
+ catch { /* non-fatal */ }
138
+ }
139
+ // ── Bucket aggregation ────────────────────────────────────────────────────
140
+ /**
141
+ * Given all quota entries, build the two UsageSnapshot rows:
142
+ * - agy-gemini: worst-case (min remaining) across all Gemini models
143
+ * - agy-other: worst-case across all non-Gemini models (Claude, etc.)
144
+ */
145
+ function buildSnapshots(entries, fromCache) {
146
+ const source = fromCache ? 'cache' : 'official-cli';
147
+ const geminiEntries = entries.filter(e => /gemini/i.test(e.model));
148
+ const otherEntries = entries.filter(e => !/gemini/i.test(e.model));
149
+ function worstCase(bucket, tool) {
150
+ if (bucket.length === 0) {
151
+ return { tool, remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' };
152
+ }
153
+ // Show the lowest remaining % (most constrained model in the bucket)
154
+ const worst = bucket.reduce((a, b) => a.percent <= b.percent ? a : b);
155
+ return {
156
+ tool,
157
+ remainingPercent: worst.percent,
158
+ usedPercent: 100 - worst.percent,
159
+ resetAt: worst.refreshLine ?? null,
160
+ source,
161
+ raw: { matchedModel: worst.model, allModels: bucket.map(e => `${e.model}: ${e.percent}%`) },
162
+ };
163
+ }
164
+ return [
165
+ worstCase(geminiEntries, 'agy-gemini'),
166
+ worstCase(otherEntries, 'agy-other'),
167
+ ];
168
+ }
169
+ // ── Adapter ───────────────────────────────────────────────────────────────
170
+ export class AgyQuotaAdapter {
171
+ async fetchSnapshots() {
172
+ // Fast path: serve from cache if fresh enough
173
+ const cached = await readCache();
174
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
175
+ return buildSnapshots(cached.entries, true);
176
+ }
177
+ // Slow path: spawn agy, scrape the quota panel
178
+ try {
179
+ const raw = await runAgyUsage();
180
+ const entries = parseQuotaPanel(raw);
181
+ if (entries.length > 0) {
182
+ await writeCache(entries);
183
+ return buildSnapshots(entries, false);
87
184
  }
88
- return {
89
- tool: 'agy',
90
- remainingPercent,
91
- usedPercent: 100 - remainingPercent,
92
- resetAt,
93
- source: 'local-state',
94
- raw: { activeModel, todayPromptsCount }
95
- };
185
+ // Panel not found — return unknown rows (do not cache failures)
186
+ return [
187
+ { tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
188
+ { tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
189
+ ];
96
190
  }
97
191
  catch (error) {
98
- return {
99
- tool: 'agy',
100
- remainingPercent: null,
101
- usedPercent: null,
102
- resetAt: null,
103
- source: 'unknown',
104
- raw: error instanceof Error ? error.message : String(error)
105
- };
192
+ const msg = error instanceof Error ? error.message : String(error);
193
+ return [
194
+ { tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown', raw: msg },
195
+ { tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown', raw: msg },
196
+ ];
106
197
  }
107
198
  }
108
199
  }
@@ -1,6 +1,7 @@
1
1
  import { QuotaAdapter, UsageSnapshot } from './index.js';
2
2
  export declare class ClaudeQuotaAdapter implements QuotaAdapter {
3
- private budgetLimit;
3
+ private readonly budgetLimit;
4
4
  constructor();
5
- fetchSnapshot(): Promise<UsageSnapshot>;
5
+ fetchSnapshots(): Promise<UsageSnapshot[]>;
6
+ private _fetch;
6
7
  }
@@ -1,48 +1,52 @@
1
1
  import { exec } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  const execAsync = promisify(exec);
4
+ // Budget limit for the rolling 5-hour billing window.
5
+ // Override with AGENT_FUEL_CLAUDE_BUDGET env var (dollars).
6
+ const DEFAULT_BUDGET_USD = 20.0;
4
7
  export class ClaudeQuotaAdapter {
5
8
  budgetLimit;
6
9
  constructor() {
7
- // Default to $10.00 for the rolling 5-hour window, allow env override
8
- this.budgetLimit = Number(process.env.AGENT_FUEL_CLAUDE_BUDGET) || 10.0;
10
+ const override = Number(process.env.AGENT_FUEL_CLAUDE_BUDGET);
11
+ this.budgetLimit = Number.isFinite(override) && override > 0 ? override : DEFAULT_BUDGET_USD;
9
12
  }
10
- async fetchSnapshot() {
13
+ async fetchSnapshots() {
14
+ return [await this._fetch()];
15
+ }
16
+ async _fetch() {
17
+ const unknown = () => ({
18
+ tool: 'claude-code',
19
+ remainingPercent: null,
20
+ usedPercent: null,
21
+ resetAt: null,
22
+ source: 'unknown',
23
+ });
11
24
  try {
12
- // Execute ccusage to get billing block information in JSON format
13
- // We run npx --no-install first to see if it's already cached/available, otherwise fall back to regular npx
14
25
  let stdout;
15
26
  try {
16
- const result = await execAsync('npx --no-install ccusage blocks --json');
17
- stdout = result.stdout;
27
+ ({ stdout } = await execAsync('npx --no-install ccusage blocks --json'));
18
28
  }
19
29
  catch {
20
- throw new Error('ccusage package is not installed or available locally. Please run "npm install -g ccusage" to use this tool.');
30
+ throw new Error('ccusage not found. Run "npm install -g ccusage" to enable Claude Code tracking.');
21
31
  }
22
32
  const data = JSON.parse(stdout);
23
- const blocks = data && Array.isArray(data.blocks) ? data.blocks : data;
24
- if (!blocks || !Array.isArray(blocks)) {
25
- throw new Error('Invalid JSON format returned from ccusage blocks');
26
- }
27
- // Find the active billing block
28
- const activeBlock = blocks.find((block) => block.isActive === true);
29
- if (!activeBlock) {
30
- return {
31
- tool: 'claude-code',
32
- remainingPercent: null,
33
- usedPercent: null,
34
- resetAt: null,
35
- source: 'unknown'
36
- };
33
+ const blocks = Array.isArray(data?.blocks) ? data.blocks : data;
34
+ if (!Array.isArray(blocks)) {
35
+ throw new Error('Unexpected JSON shape from ccusage blocks.');
37
36
  }
38
- const cost = activeBlock.costUSD || 0.0;
39
- const usedPercent = (cost / this.budgetLimit) * 100;
40
- const remainingPercent = Math.max(0, Math.min(100, Math.round(100 - usedPercent)));
37
+ const activeBlock = blocks.find((b) => b.isActive === true);
38
+ if (!activeBlock)
39
+ return unknown();
40
+ const cost = typeof activeBlock.costUSD === 'number' ? activeBlock.costUSD : 0;
41
+ const usedPct = (cost / this.budgetLimit) * 100;
42
+ const remainingPercent = Math.max(0, Math.min(100, Math.round(100 - usedPct)));
41
43
  let resetAt = null;
42
- if (activeBlock.endTime) {
44
+ if (typeof activeBlock.endTime === 'string') {
43
45
  try {
44
- const endDate = new Date(activeBlock.endTime);
45
- resetAt = endDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
46
+ resetAt = new Date(activeBlock.endTime).toLocaleTimeString([], {
47
+ hour: '2-digit',
48
+ minute: '2-digit',
49
+ });
46
50
  }
47
51
  catch {
48
52
  resetAt = activeBlock.endTime;
@@ -51,21 +55,16 @@ export class ClaudeQuotaAdapter {
51
55
  return {
52
56
  tool: 'claude-code',
53
57
  remainingPercent,
54
- usedPercent: Math.round(usedPercent),
58
+ usedPercent: Math.round(usedPct),
55
59
  resetAt,
56
60
  source: 'ccusage',
57
- raw: activeBlock
61
+ raw: activeBlock,
58
62
  };
59
63
  }
60
64
  catch (error) {
61
- // Fallback in case of execution errors
62
65
  return {
63
- tool: 'claude-code',
64
- remainingPercent: null,
65
- usedPercent: null,
66
- resetAt: null,
67
- source: 'unknown',
68
- raw: error instanceof Error ? error.message : String(error)
66
+ ...unknown(),
67
+ raw: error instanceof Error ? error.message : String(error),
69
68
  };
70
69
  }
71
70
  }
@@ -1,6 +1,7 @@
1
1
  import { QuotaAdapter, UsageSnapshot } from './index.js';
2
2
  export declare class CodexQuotaAdapter implements QuotaAdapter {
3
- private budgetLimit;
3
+ private readonly budgetLimit;
4
4
  constructor();
5
- fetchSnapshot(): Promise<UsageSnapshot>;
5
+ fetchSnapshots(): Promise<UsageSnapshot[]>;
6
+ private _fetch;
6
7
  }
@@ -1,105 +1,181 @@
1
- import { exec } from 'node:child_process';
1
+ import { exec, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  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;
4
+ // Used ONLY as a rough fallback estimate when the TUI scrape cannot determine
5
+ // a percentage (i.e. quota has not yet been reached). This is a GUESS based on
6
+ // local session cost data — not an official Codex quota signal.
7
+ // Override with AGENT_FUEL_CODEX_BUDGET env var (dollars).
8
+ const DEFAULT_BUDGET_USD = 20.0;
9
+ const ROLLING_WINDOW_MS = 5 * 60 * 60 * 1000;
10
+ // ── TUI scraper (expect) ───────────────────────────────────────────────────
11
+ /**
12
+ * Spawns `codex` via `expect`, handles the trust prompt, waits for the TUI to
13
+ * settle, then captures stdout/stderr to check for the quota-reached warning.
14
+ */
15
+ function runCodexScrape() {
16
+ return new Promise((resolve) => {
17
+ const expectScript = [
18
+ 'set timeout 15',
19
+ 'spawn codex',
20
+ 'expect {',
21
+ ' -re "Press enter to continue" { send "\\r"; exp_continue }',
22
+ ' -re "Individual quota reached" { after 300; send "\\x03" }',
23
+ ' -re "for shortcuts" { after 300; send "\\x03" }',
24
+ ' timeout { }',
25
+ ' eof { }',
26
+ '}',
27
+ 'expect eof',
28
+ ].join('\n');
29
+ const MAX_OUTPUT_BYTES = 64 * 1024;
30
+ let output = '';
31
+ const child = spawn('expect', ['-c', expectScript], {
32
+ env: { ...process.env },
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ });
35
+ const append = (chunk) => {
36
+ if (output.length < MAX_OUTPUT_BYTES)
37
+ output += chunk.toString();
38
+ };
39
+ child.stdout.on('data', append);
40
+ child.stderr.on('data', append);
41
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(output); }, 20_000);
42
+ child.on('close', () => { clearTimeout(timer); resolve(output); });
43
+ });
44
+ }
45
+ // ── Output parser ──────────────────────────────────────────────────────────
46
+ function stripAnsi(str) {
47
+ // eslint-disable-next-line no-control-regex
48
+ return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B[^[]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
49
+ }
50
+ function parseScrapeOutput(raw) {
51
+ const clean = stripAnsi(raw);
52
+ // "Individual quota reached. Contact your administrator to enable overages. Resets in 4h33m29s."
53
+ const quotaMatch = clean.match(/Individual quota reached/i);
54
+ if (!quotaMatch)
55
+ return { quotaReached: false, resetIn: null };
56
+ // Parse "Resets in 4h33m29s" → "4h 33m"
57
+ const resetMatch = clean.match(/Resets in\s*((?:\d+h)?(?:\d+m)?(?:\d+s)?)/i);
58
+ let resetIn = null;
59
+ if (resetMatch) {
60
+ const parts = [];
61
+ const hm = resetMatch[1].match(/^(\d+h)?(\d+m)?/);
62
+ if (hm) {
63
+ if (hm[1])
64
+ parts.push(hm[1]);
65
+ if (hm[2])
66
+ parts.push(hm[2]);
67
+ }
68
+ resetIn = parts.length > 0 ? parts.join(' ') : null;
10
69
  }
11
- async fetchSnapshot() {
70
+ return { quotaReached: true, resetIn };
71
+ }
72
+ // ── ccusage fallback estimate ──────────────────────────────────────────────
73
+ async function fetchCcusageEstimate(budgetLimit) {
74
+ const unknown = () => ({
75
+ tool: 'codex',
76
+ remainingPercent: null,
77
+ usedPercent: null,
78
+ resetAt: null,
79
+ source: 'unknown',
80
+ });
81
+ try {
82
+ let stdout;
12
83
  try {
13
- // Execute ccusage to get Codex session data
14
- let stdout;
84
+ ({ stdout } = await execAsync('npx --no-install ccusage codex session --json'));
85
+ }
86
+ catch {
87
+ return unknown();
88
+ }
89
+ const data = JSON.parse(stdout);
90
+ const sessions = Array.isArray(data?.sessions) ? data.sessions :
91
+ Array.isArray(data?.session) ? data.session :
92
+ Array.isArray(data) ? data : [];
93
+ if (sessions.length === 0) {
94
+ return { tool: 'codex', remainingPercent: 100, usedPercent: 0, resetAt: null, source: 'ccusage' };
95
+ }
96
+ const todayStr = localDateString(new Date());
97
+ const todaySessions = sessions.filter((s) => {
98
+ if (typeof s.lastActivity !== 'string')
99
+ return false;
15
100
  try {
16
- const result = await execAsync('npx --no-install ccusage codex session --json');
17
- stdout = result.stdout;
101
+ return localDateString(new Date(s.lastActivity)) === todayStr;
18
102
  }
19
103
  catch {
20
- throw new Error('ccusage package is not installed or available locally. Please run "npm install -g ccusage" to use this tool.');
104
+ return false;
21
105
  }
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');
106
+ });
107
+ if (todaySessions.length === 0) {
108
+ return { tool: 'codex', remainingPercent: 100, usedPercent: 0, resetAt: null, source: 'ccusage' };
109
+ }
110
+ const totalCost = todaySessions.reduce((acc, s) => acc + (typeof s.costUSD === 'number' ? s.costUSD : 0), 0);
111
+ const usedPct = (totalCost / budgetLimit) * 100;
112
+ const rawRemaining = 100 - usedPct;
113
+ const remainingPercent = usedPct > 0 && rawRemaining > 99 ? 99
114
+ : Math.max(0, Math.min(100, Math.round(rawRemaining)));
115
+ const latestActivity = todaySessions
116
+ .map((s) => new Date(s.lastActivity).getTime())
117
+ .reduce((a, b) => (b > a ? b : a), 0);
118
+ let resetAt = null;
119
+ if (latestActivity > 0) {
120
+ try {
121
+ resetAt = new Date(latestActivity + ROLLING_WINDOW_MS).toLocaleTimeString([], {
122
+ hour: '2-digit', minute: '2-digit',
123
+ });
26
124
  }
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
125
+ catch { /* leave null */ }
126
+ }
127
+ return {
128
+ tool: 'codex',
129
+ remainingPercent,
130
+ usedPercent: Math.round(usedPct),
131
+ resetAt,
132
+ source: 'ccusage',
133
+ raw: { totalCost, todaySessionsCount: todaySessions.length, isEstimate: true },
134
+ };
135
+ }
136
+ catch {
137
+ return unknown();
138
+ }
139
+ }
140
+ // ── Adapter ────────────────────────────────────────────────────────────────
141
+ export class CodexQuotaAdapter {
142
+ budgetLimit;
143
+ constructor() {
144
+ const override = Number(process.env.AGENT_FUEL_CODEX_BUDGET);
145
+ this.budgetLimit = Number.isFinite(override) && override > 0 ? override : DEFAULT_BUDGET_USD;
146
+ }
147
+ async fetchSnapshots() {
148
+ return [await this._fetch()];
149
+ }
150
+ async _fetch() {
151
+ // Primary: scrape the Codex TUI via expect
152
+ try {
153
+ const raw = await runCodexScrape();
154
+ const result = parseScrapeOutput(raw);
155
+ if (result.quotaReached) {
156
+ // Ground truth: quota is exhausted
157
+ const resetAt = result.resetIn ? `Resets in ${result.resetIn}` : null;
50
158
  return {
51
159
  tool: 'codex',
52
- remainingPercent: 100,
53
- usedPercent: 0,
54
- resetAt: null,
55
- source: 'ccusage'
160
+ remainingPercent: 0,
161
+ usedPercent: 100,
162
+ resetAt,
163
+ source: 'official-cli',
56
164
  };
57
165
  }
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
- }
84
- }
85
- return {
86
- tool: 'codex',
87
- remainingPercent,
88
- usedPercent: Math.round(usedPercent),
89
- resetAt,
90
- source: 'ccusage',
91
- raw: { totalCost, todaySessionsCount: todaySessions.length }
92
- };
166
+ // TUI loaded cleanly with no quota warning → estimate remaining via ccusage
167
+ const estimate = await fetchCcusageEstimate(this.budgetLimit);
168
+ return estimate;
93
169
  }
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
- };
170
+ catch {
171
+ // expect not available or codex spawn failed → fall back to ccusage estimate
172
+ return fetchCcusageEstimate(this.budgetLimit);
103
173
  }
104
174
  }
105
175
  }
176
+ function localDateString(date) {
177
+ const y = date.getFullYear();
178
+ const m = String(date.getMonth() + 1).padStart(2, '0');
179
+ const d = String(date.getDate()).padStart(2, '0');
180
+ return `${y}-${m}-${d}`;
181
+ }
@@ -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
  }
package/dist/index.js CHANGED
@@ -2,28 +2,54 @@
2
2
  import { ClaudeQuotaAdapter } from './adapters/claude.js';
3
3
  import { CodexQuotaAdapter } from './adapters/codex.js';
4
4
  import { AgyQuotaAdapter } from './adapters/agy.js';
5
- import { renderDashboard } from './render.js';
5
+ import { printHeader, printFooter, formatRow, getDisplayName, LOADING_LINE } from './render.js';
6
+ // Fixed display order — never changes regardless of which adapter resolves first
7
+ const SLOT_ORDER = ['claude-code', 'codex', 'agy-gemini', 'agy-other'];
8
+ const BOLD = '\x1b[1m';
9
+ const R = '\x1b[0m';
10
+ const N = SLOT_ORDER.length;
11
+ // Redraws all N slot lines from the current cursor position (cursor must be
12
+ // just below the last slot line when called).
13
+ function redraw(slots) {
14
+ process.stdout.write(`\x1b[${N}A`); // cursor up N lines
15
+ for (const tool of SLOT_ORDER) {
16
+ process.stdout.write('\x1b[2K\r'); // clear line
17
+ const line = slots.get(tool);
18
+ if (line != null) {
19
+ process.stdout.write(line + '\n');
20
+ }
21
+ else {
22
+ process.stdout.write(`${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${LOADING_LINE}\n`);
23
+ }
24
+ }
25
+ }
6
26
  async function main() {
7
27
  const claudeAdapter = new ClaudeQuotaAdapter();
8
28
  const codexAdapter = new CodexQuotaAdapter();
9
29
  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
- ]);
30
+ printHeader();
31
+ // Print placeholder rows in fixed order
32
+ const slots = new Map(SLOT_ORDER.map(t => [t, null]));
33
+ for (const tool of SLOT_ORDER) {
34
+ process.stdout.write(`${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${LOADING_LINE}\n`);
23
35
  }
24
- catch (error) {
25
- console.error('\x1b[31mFatal error orchestrating Agent Fuel CLI:\x1b[0m', error);
26
- process.exit(1);
36
+ // Each adapter fills its slot(s) and triggers a redraw; order is always fixed
37
+ function fill(snaps) {
38
+ for (const snap of snaps) {
39
+ if (SLOT_ORDER.includes(snap.tool)) {
40
+ slots.set(snap.tool, formatRow(snap));
41
+ }
42
+ }
43
+ redraw(slots);
27
44
  }
45
+ await Promise.allSettled([
46
+ claudeAdapter.fetchSnapshots().then(fill),
47
+ codexAdapter.fetchSnapshots().then(fill),
48
+ agyAdapter.fetchSnapshots().then(fill),
49
+ ]);
50
+ printFooter();
28
51
  }
29
- main();
52
+ main().catch((error) => {
53
+ console.error('\x1b[31mFatal error orchestrating Agent Fuel CLI:\x1b[0m', error);
54
+ process.exit(1);
55
+ });
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,61 +1,113 @@
1
- export function renderDashboard(snapshots) {
2
- const reset = '\x1b[0m';
3
- const bold = '\x1b[1m';
4
- const dim = '\x1b[2m';
5
- const cyan = '\x1b[36m';
6
- const green = '\x1b[32m';
7
- const yellow = '\x1b[33m';
8
- const red = '\x1b[31m';
9
- const gray = '\x1b[90m';
10
- console.log(`\n${bold}${cyan}⚡️ Agent Fuel - CLI Quota Monitor${reset}\n`);
11
- for (const snap of snapshots) {
12
- const displayName = getDisplayName(snap.tool);
13
- const remaining = snap.remainingPercent;
14
- const width = 30;
15
- let barStr = '';
16
- let percentStr = '';
17
- if (remaining === null || remaining === undefined) {
18
- // Unknown/Unconfigured quota
19
- barStr = `${gray}${'░'.repeat(width)}${reset}`;
20
- percentStr = `${gray}unknown${reset}`;
21
- }
22
- else {
23
- const filled = Math.max(0, Math.min(width, Math.round((remaining * width) / 100)));
24
- const empty = width - filled;
25
- // Color scheme based on remaining percentage
26
- let color = green;
27
- if (remaining < 20) {
28
- color = red;
29
- }
30
- else if (remaining < 50) {
31
- color = yellow;
32
- }
33
- const blockChar = '█';
34
- const shadeChar = '░';
35
- barStr = `${color}${blockChar.repeat(filled)}${reset}${gray}${shadeChar.repeat(empty)}${reset}`;
36
- percentStr = `${bold}${color}${remaining.toString().padStart(3)}% remaining${reset}`;
37
- }
38
- // Add metadata/reset times if available
39
- let detailStr = '';
40
- if (snap.resetAt) {
41
- detailStr = ` ${dim}${gray}(resets ${snap.resetAt})${reset}`;
42
- }
43
- if (snap.tool === 'agy' && snap.raw && typeof snap.raw === 'object' && 'activeModel' in snap.raw) {
44
- detailStr += ` ${dim}${gray}[${snap.raw.activeModel}]${reset}`;
45
- }
46
- console.log(`${bold}${displayName.padEnd(12)}${reset} [${barStr}] ${percentStr}${detailStr}`);
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
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;
47
25
  }
48
- console.log('');
49
26
  }
50
- function getDisplayName(tool) {
51
- switch (tool) {
52
- case 'codex':
53
- return 'Codex';
54
- case 'claude-code':
55
- return 'Claude Code';
56
- case 'agy':
57
- return 'AGY';
58
- default:
59
- return tool;
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() {
35
+ try {
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}`;
39
+ }
40
+ catch {
41
+ return '';
42
+ }
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
  }
56
+ return `${DIM}${GRAY}(resets ${resetAt})${R}`;
57
+ }
58
+ function isEstimate(snap) {
59
+ return snap.source === 'ccusage' &&
60
+ typeof snap.raw === 'object' && snap.raw !== null &&
61
+ snap.raw.isEstimate === true;
62
+ }
63
+ // ── Core format (returns string, no newline) ───────────────────────────────
64
+ export function formatRow(snap) {
65
+ const displayName = getDisplayName(snap.tool);
66
+ const remaining = snap.remainingPercent;
67
+ let barStr;
68
+ let percentStr;
69
+ if (remaining === null) {
70
+ barStr = `${GRAY}${SHADE_CHAR.repeat(BAR_WIDTH)}${R}`;
71
+ percentStr = `${GRAY}unknown${R}`;
72
+ }
73
+ else {
74
+ const colour = pickColour(remaining);
75
+ const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((remaining * BAR_WIDTH) / 100)));
76
+ const empty = BAR_WIDTH - filled;
77
+ barStr = `${colour}${BLOCK_CHAR.repeat(filled)}${R}${GRAY}${SHADE_CHAR.repeat(empty)}${R}`;
78
+ percentStr = `${BOLD}${colour}${remaining.toString().padStart(3)}% remaining${R}`;
79
+ }
80
+ const parts = [];
81
+ if (snap.resetAt)
82
+ parts.push(formatResetAt(snap.resetAt));
83
+ if ((snap.tool === 'agy-gemini' || snap.tool === 'agy-other') &&
84
+ snap.raw && typeof snap.raw === 'object') {
85
+ const label = snap.raw.matchedModel;
86
+ if (typeof label === 'string' && label) {
87
+ parts.push(`${DIM}${GRAY}[${label}]${R}`);
88
+ }
89
+ }
90
+ if (snap.tool === 'codex' && isEstimate(snap)) {
91
+ parts.push(`${DIM}${GRAY}[~est]${R}`);
92
+ }
93
+ const detailStr = parts.length > 0 ? ` ${parts.join(' ')}` : '';
94
+ return `${BOLD}${displayName.padEnd(13)}${R} [${barStr}] ${percentStr}${detailStr}`;
95
+ }
96
+ // ── Public render functions ────────────────────────────────────────────────
97
+ export function printHeader() {
98
+ console.log(`\n${BOLD}${CYAN}⚡️ Agent Fuel - CLI Quota Monitor${R}\n`);
99
+ }
100
+ export function printRow(snap) {
101
+ process.stdout.write(formatRow(snap) + '\n');
102
+ }
103
+ export function printFooter() {
104
+ console.log(`\n${DIM}${GRAY}agent-fuel${loadVersion()}${R}\n`);
105
+ }
106
+ export const LOADING_LINE = `${DIM}${GRAY}loading...${R}`;
107
+ /** Convenience wrapper — renders a full static dashboard in one call. */
108
+ export function renderDashboard(snapshots) {
109
+ printHeader();
110
+ for (const snap of snapshots)
111
+ printRow(snap);
112
+ printFooter();
61
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-fuel",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sleek term-based dashboard for AI coding CLI quotas",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,4 +31,4 @@
31
31
  "@types/node": "^20.11.24",
32
32
  "typescript": "^5.3.3"
33
33
  }
34
- }
34
+ }