agent-limit 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 ADDED
@@ -0,0 +1,128 @@
1
+ # agent-limit
2
+
3
+ Terminal dashboard to monitor Claude Code, Codex, and Gemini CLI usage limits.
4
+
5
+ ## Install
6
+
7
+ ### Via npm (requires Bun)
8
+
9
+ ```bash
10
+ npm install -g agent-limit
11
+ ```
12
+
13
+ ### Standalone Binary (no dependencies)
14
+
15
+ Download from [GitHub Releases](https://github.com/AgentWorkforce/limit/releases):
16
+
17
+ ```bash
18
+ # Apple Silicon
19
+ curl -L https://github.com/AgentWorkforce/limit/releases/latest/download/agent-limit-darwin-arm64 -o /usr/local/bin/agent-limit
20
+ chmod +x /usr/local/bin/agent-limit
21
+
22
+ # Intel Mac
23
+ curl -L https://github.com/AgentWorkforce/limit/releases/latest/download/agent-limit-darwin-x64 -o /usr/local/bin/agent-limit
24
+ chmod +x /usr/local/bin/agent-limit
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ agent-limit usage
31
+ ```
32
+
33
+ ## CLI
34
+
35
+ | Command | Description |
36
+ |---------|-------------|
37
+ | `agent-limit usage` | Show usage dashboard |
38
+ | `agent-limit help` | Show help message |
39
+
40
+ ## Dashboard Controls
41
+
42
+ | Key | Action |
43
+ |-----|--------|
44
+ | `q` | Quit |
45
+ | `r` | Refresh |
46
+
47
+ ## Features
48
+
49
+ - Real-time usage tracking for Claude Code, Codex, and Gemini CLI
50
+ - Trajectory markers showing if you're ahead or behind your usage pace
51
+ - Auto-refresh every 60 seconds
52
+ - Color-coded usage indicators
53
+
54
+ ## Supported Providers
55
+
56
+ | Provider | Status | Data Source |
57
+ |----------|--------|-------------|
58
+ | Claude Code | Full support | macOS Keychain + Anthropic API |
59
+ | Codex | Full support | `~/.codex/auth.json` + OpenAI API |
60
+ | Gemini CLI | Static limits | `~/.gemini/settings.json` |
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ git clone https://github.com/AgentWorkforce/limit.git
66
+ cd monitor
67
+ bun install
68
+ ```
69
+
70
+ Run in development mode with hot reload:
71
+
72
+ ```bash
73
+ bun run dev
74
+ ```
75
+
76
+ Run directly:
77
+
78
+ ```bash
79
+ bun run start
80
+ ```
81
+
82
+ > **Note:** In dev mode, use `q` to quit cleanly. If you Ctrl-C and see garbled output, run `reset` to restore your terminal.
83
+
84
+ ### Building Standalone Binaries
85
+
86
+ Build binaries that don't require Bun:
87
+
88
+ ```bash
89
+ # Build for all macOS architectures
90
+ bun run build
91
+
92
+ # Build for specific architecture
93
+ bun run build:arm64 # Apple Silicon
94
+ bun run build:x64 # Intel
95
+ ```
96
+
97
+ Binaries are output to `dist/`.
98
+
99
+ ## How It Works
100
+
101
+ agent-limit reads credentials from standard locations:
102
+
103
+ - **Claude Code**: macOS Keychain (`Claude Code-credentials`)
104
+ - **Codex**: `~/.codex/auth.json`
105
+ - **Gemini**: `~/.gemini/settings.json`
106
+
107
+ It then fetches usage data from each provider's API and displays it in a unified dashboard.
108
+
109
+ ### Trajectory Indicator
110
+
111
+ Each progress bar shows a `|` marker indicating where your usage "should be" based on time elapsed in the reset period:
112
+
113
+ ```
114
+ [███████░░░░|░░░░░░░░░] 30% ↓12%
115
+ ^ you should be at 42%, but you're at 30% (12% under pace)
116
+ ```
117
+
118
+ - `↓X%` (green) = under pace, you have headroom
119
+ - `↑X%` (red) = over pace, might hit limits early
120
+
121
+ ## Requirements
122
+
123
+ - macOS (uses Keychain for credential storage)
124
+ - Active CLI authentication for providers you want to monitor
125
+
126
+ ## License
127
+
128
+ MIT
package/bin/cli.tsx ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bun
2
+ /** @jsxImportSource @opentui/react */
3
+
4
+ import { createRoot } from "@opentui/react";
5
+ import { App } from "../src/App";
6
+
7
+ function resetTerminal() {
8
+ process.stdout.write("\x1b[?1049l");
9
+ process.stdout.write("\x1b[?25h");
10
+ process.stdout.write("\x1b[0m");
11
+ process.stdout.write("\x1b[?1000l");
12
+ process.stdout.write("\x1b[?1002l");
13
+ process.stdout.write("\x1b[?1003l");
14
+ process.stdout.write("\x1b[?1006l");
15
+ process.stdout.write("\x1b[?2004l");
16
+ }
17
+
18
+ const command = process.argv[2];
19
+
20
+ if (command === "usage") {
21
+ let root: ReturnType<typeof createRoot> | null = null;
22
+
23
+ const cleanup = () => {
24
+ if (root) {
25
+ root.unmount();
26
+ }
27
+ resetTerminal();
28
+ process.exit(0);
29
+ };
30
+
31
+ process.on("SIGINT", cleanup);
32
+ process.on("SIGTERM", cleanup);
33
+ process.on("exit", resetTerminal);
34
+
35
+ root = createRoot(process.stdout, { hideCursor: true });
36
+ root.render(<App onExit={cleanup} />);
37
+ } else if (command === "help" || command === "--help" || command === "-h" || !command) {
38
+ console.log(`
39
+ agent-monitor
40
+
41
+ Monitor AI agent CLI usage limits in real-time.
42
+
43
+ Install:
44
+ npm install -g agent-monitor
45
+
46
+ Quick Start:
47
+ agent-monitor usage
48
+
49
+ CLI:
50
+ agent-monitor usage Show usage dashboard
51
+ agent-monitor help Show this help message
52
+
53
+ Dashboard Controls:
54
+ q Quit
55
+ r Refresh
56
+ `);
57
+ } else {
58
+ console.error(`Unknown command: ${command}`);
59
+ console.error(`Run 'agent-monitor help' for usage.`);
60
+ process.exit(1);
61
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "agent-limit",
3
+ "version": "0.3.0",
4
+ "description": "Terminal dashboard to monitor Claude Code, Codex, and other agent usage limits",
5
+ "type": "module",
6
+ "main": "src/index.tsx",
7
+ "bin": {
8
+ "agent-limit": "./bin/cli.tsx"
9
+ },
10
+ "scripts": {
11
+ "start": "bun run src/index.tsx",
12
+ "dev": "bun --watch run src/index.tsx",
13
+ "build": "bun build --compile --target=bun-darwin-arm64 ./bin/cli.tsx --outfile=dist/agent-limit-darwin-arm64 && bun build --compile --target=bun-darwin-x64 ./bin/cli.tsx --outfile=dist/agent-limit-darwin-x64",
14
+ "build:arm64": "bun build --compile --target=bun-darwin-arm64 ./bin/cli.tsx --outfile=dist/agent-limit-darwin-arm64",
15
+ "build:x64": "bun build --compile --target=bun-darwin-x64 ./bin/cli.tsx --outfile=dist/agent-limit-darwin-x64"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src"
20
+ ],
21
+ "keywords": [
22
+ "cli",
23
+ "terminal",
24
+ "dashboard",
25
+ "claude",
26
+ "codex",
27
+ "gemini",
28
+ "ai",
29
+ "agent",
30
+ "usage",
31
+ "limits",
32
+ "monitor",
33
+ "tui"
34
+ ],
35
+ "author": "Will Washburn",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/AgentWorkforce/limit.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/AgentWorkforce/limit/issues"
43
+ },
44
+ "homepage": "https://github.com/AgentWorkforce/limit",
45
+ "engines": {
46
+ "bun": ">=1.0.0"
47
+ },
48
+ "os": [
49
+ "darwin"
50
+ ],
51
+ "dependencies": {
52
+ "@opentui/core": "^0.1.67",
53
+ "@opentui/react": "^0.1.63",
54
+ "react": "^19.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/bun": "latest",
58
+ "@types/react": "^19.0.0",
59
+ "typescript": "^5.0.0"
60
+ }
61
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,64 @@
1
+ import { useEffect, useState, useCallback } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { Header, Footer, Dashboard } from "./components";
4
+ import { fetchAllProviders, type ProviderStatus } from "./providers";
5
+
6
+ const REFRESH_INTERVAL = 60000;
7
+
8
+ interface AppProps {
9
+ onExit?: () => void;
10
+ }
11
+
12
+ export function App({ onExit }: AppProps) {
13
+ const [providers, setProviders] = useState<ProviderStatus[]>([
14
+ { provider: "claude", status: "loading", metrics: [] },
15
+ { provider: "codex", status: "loading", metrics: [] },
16
+ { provider: "gemini", status: "loading", metrics: [] },
17
+ ]);
18
+ const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
19
+ const [isLoading, setIsLoading] = useState(true);
20
+
21
+ const refresh = useCallback(async () => {
22
+ setIsLoading(true);
23
+ try {
24
+ const results = await fetchAllProviders();
25
+ setProviders(results);
26
+ setLastRefresh(new Date());
27
+ } catch (err) {
28
+ console.error("Failed to fetch providers:", err);
29
+ } finally {
30
+ setIsLoading(false);
31
+ }
32
+ }, []);
33
+
34
+ useEffect(() => {
35
+ refresh();
36
+ const interval = setInterval(refresh, REFRESH_INTERVAL);
37
+ return () => clearInterval(interval);
38
+ }, [refresh]);
39
+
40
+ useKeyboard((key) => {
41
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
42
+ onExit?.();
43
+ }
44
+
45
+ if (key.name === "r") {
46
+ refresh();
47
+ }
48
+ });
49
+
50
+ return (
51
+ <box
52
+ style={{
53
+ width: "100%",
54
+ height: "100%",
55
+ flexDirection: "column",
56
+ backgroundColor: "#0a0a0f",
57
+ }}
58
+ >
59
+ <Header lastRefresh={lastRefresh} isLoading={isLoading} />
60
+ <Dashboard providers={providers} />
61
+ <Footer />
62
+ </box>
63
+ );
64
+ }
@@ -0,0 +1,23 @@
1
+ import type { ProviderStatus } from "../providers/types";
2
+ import { ProviderCard } from "./ProviderCard";
3
+
4
+ interface DashboardProps {
5
+ providers: ProviderStatus[];
6
+ }
7
+
8
+ export function Dashboard({ providers }: DashboardProps) {
9
+ return (
10
+ <box
11
+ style={{
12
+ flexGrow: 1,
13
+ flexDirection: "row",
14
+ gap: 1,
15
+ padding: 1,
16
+ }}
17
+ >
18
+ {providers.map((provider) => (
19
+ <ProviderCard key={provider.provider} data={provider} />
20
+ ))}
21
+ </box>
22
+ );
23
+ }
@@ -0,0 +1,19 @@
1
+ export function Footer() {
2
+ return (
3
+ <box
4
+ style={{
5
+ width: "100%",
6
+ height: 1,
7
+ flexDirection: "row",
8
+ paddingLeft: 2,
9
+ paddingRight: 2,
10
+ }}
11
+ >
12
+ <text fg="#6b7280">
13
+ <span fg="#9ca3af">q</span>: quit{" "}
14
+ <span fg="#9ca3af">r</span>: refresh{" "}
15
+ <span fg="#9ca3af">?</span>: help
16
+ </text>
17
+ </box>
18
+ );
19
+ }
@@ -0,0 +1,41 @@
1
+ interface HeaderProps {
2
+ lastRefresh: Date | null;
3
+ isLoading: boolean;
4
+ }
5
+
6
+ function formatTime(date: Date): string {
7
+ return date.toLocaleTimeString("en-US", {
8
+ hour: "numeric",
9
+ minute: "2-digit",
10
+ hour12: true,
11
+ });
12
+ }
13
+
14
+ export function Header({ lastRefresh, isLoading }: HeaderProps) {
15
+ const refreshText = isLoading
16
+ ? "Refreshing..."
17
+ : lastRefresh
18
+ ? `Last refresh: ${formatTime(lastRefresh)}`
19
+ : "Loading...";
20
+
21
+ return (
22
+ <box
23
+ style={{
24
+ width: "100%",
25
+ height: 3,
26
+ flexDirection: "row",
27
+ justifyContent: "space-between",
28
+ alignItems: "center",
29
+ paddingLeft: 2,
30
+ paddingRight: 2,
31
+ borderStyle: "single",
32
+ borderColor: "#0f3460",
33
+ }}
34
+ >
35
+ <text fg="#e5e5e5">
36
+ <span fg="#3b82f6">AI Limits Monitor</span>
37
+ </text>
38
+ <text fg="#6b7280">{refreshText}</text>
39
+ </box>
40
+ );
41
+ }
@@ -0,0 +1,81 @@
1
+ import { getUsageColor } from "../utils/colors";
2
+
3
+ interface ProgressBarProps {
4
+ percentage: number;
5
+ width?: number;
6
+ showLabel?: boolean;
7
+ periodSeconds?: number;
8
+ resetsAt?: string | null;
9
+ }
10
+
11
+ function calculateTrajectory(periodSeconds: number, resetsAt: string): number {
12
+ const resetTime = new Date(resetsAt).getTime();
13
+ const now = Date.now();
14
+ const secondsRemaining = Math.max(0, (resetTime - now) / 1000);
15
+ const elapsedSeconds = periodSeconds - secondsRemaining;
16
+ return Math.min(100, Math.max(0, (elapsedSeconds / periodSeconds) * 100));
17
+ }
18
+
19
+ export function ProgressBar({
20
+ percentage,
21
+ width = 10,
22
+ showLabel = true,
23
+ periodSeconds,
24
+ resetsAt,
25
+ }: ProgressBarProps) {
26
+ if (percentage < 0) {
27
+ return <text fg="#6b7280">N/A</text>;
28
+ }
29
+
30
+ const color = getUsageColor(percentage);
31
+ const filledCount = Math.round((percentage / 100) * width);
32
+
33
+ const canShowTrajectory = periodSeconds && resetsAt;
34
+ const trajectoryPercent = canShowTrajectory
35
+ ? calculateTrajectory(periodSeconds, resetsAt)
36
+ : null;
37
+ const trajectoryPos = trajectoryPercent !== null
38
+ ? Math.round((trajectoryPercent / 100) * width)
39
+ : null;
40
+
41
+ const delta = trajectoryPercent !== null ? percentage - trajectoryPercent : null;
42
+
43
+ let deltaText = "";
44
+ let deltaColor = "#6b7280";
45
+ if (delta !== null) {
46
+ const absDelta = Math.abs(Math.round(delta));
47
+ if (delta < -1) {
48
+ deltaText = ` ↓${absDelta}%`;
49
+ deltaColor = "#22c55e";
50
+ } else if (delta > 1) {
51
+ deltaText = ` ↑${absDelta}%`;
52
+ deltaColor = "#ef4444";
53
+ } else {
54
+ deltaText = ` ±0%`;
55
+ deltaColor = "#6b7280";
56
+ }
57
+ }
58
+
59
+ const barChars: Array<{ char: string; color: string }> = [];
60
+ for (let i = 0; i < width; i++) {
61
+ if (trajectoryPos !== null && i === trajectoryPos) {
62
+ barChars.push({ char: "|", color: "#6b7280" });
63
+ } else if (i < filledCount) {
64
+ barChars.push({ char: "█", color });
65
+ } else {
66
+ barChars.push({ char: "░", color: "#4b5563" });
67
+ }
68
+ }
69
+
70
+ const label = showLabel ? ` ${Math.round(percentage)}%` : "";
71
+
72
+ return (
73
+ <text>
74
+ {barChars.map((c, i) => (
75
+ <span key={i} fg={c.color}>{c.char}</span>
76
+ ))}
77
+ <span fg={color}>{label}</span>
78
+ <span fg={deltaColor}>{deltaText}</span>
79
+ </text>
80
+ );
81
+ }
@@ -0,0 +1,82 @@
1
+ import type { ProviderStatus } from "../providers/types";
2
+ import { PROVIDER_COLORS, getStatusColor } from "../utils/colors";
3
+ import { ProgressBar } from "./ProgressBar";
4
+
5
+ interface ProviderCardProps {
6
+ data: ProviderStatus;
7
+ }
8
+
9
+ function getProviderDisplayName(provider: string): string {
10
+ switch (provider) {
11
+ case "claude": return "CLAUDE CODE";
12
+ case "codex": return "CODEX";
13
+ case "gemini": return "GEMINI CLI";
14
+ default: return provider.toUpperCase();
15
+ }
16
+ }
17
+
18
+ export function ProviderCard({ data }: ProviderCardProps) {
19
+ const providerColor = PROVIDER_COLORS[data.provider] || "#9ca3af";
20
+ const statusColor = getStatusColor(data.status);
21
+
22
+ return (
23
+ <box
24
+ style={{
25
+ flexGrow: 1,
26
+ flexBasis: 0,
27
+ minWidth: 20,
28
+ border: true,
29
+ borderStyle: "single",
30
+ borderColor: "#0f3460",
31
+ padding: 1,
32
+ flexDirection: "column",
33
+ gap: 1,
34
+ }}
35
+ >
36
+ <box style={{ flexDirection: "row", justifyContent: "space-between" }}>
37
+ <text fg={providerColor}>
38
+ <strong>{getProviderDisplayName(data.provider)}</strong>
39
+ </text>
40
+ {data.plan && <text fg="#6b7280">{data.plan}</text>}
41
+ </box>
42
+
43
+ {data.status === "unavailable" || data.status === "error" ? (
44
+ <box style={{ flexDirection: "column", gap: 1, marginTop: 1 }}>
45
+ {data.error && <text fg="#ef4444">{data.error}</text>}
46
+ {data.message && <text fg="#6b7280">{data.message}</text>}
47
+ </box>
48
+ ) : data.status === "limited" ? (
49
+ <box style={{ flexDirection: "column", gap: 1, marginTop: 1 }}>
50
+ {data.metrics.map((metric, i) => (
51
+ <box key={i} style={{ flexDirection: "column" }}>
52
+ <text fg="#9ca3af">{metric.name}</text>
53
+ <text fg="#e5e5e5">{metric.resetsIn}</text>
54
+ </box>
55
+ ))}
56
+ {data.message && (
57
+ <text fg="#6b7280" style={{ marginTop: 1 }}>
58
+ {data.message}
59
+ </text>
60
+ )}
61
+ </box>
62
+ ) : (
63
+ <box style={{ flexDirection: "column", gap: 1, marginTop: 1 }}>
64
+ {data.metrics.map((metric, i) => (
65
+ <box key={i} style={{ flexDirection: "column" }}>
66
+ <text fg="#9ca3af">{metric.name}</text>
67
+ <box style={{ flexDirection: "row", alignItems: "center", gap: 1 }}>
68
+ <ProgressBar
69
+ percentage={metric.percentage}
70
+ width={12}
71
+ periodSeconds={metric.periodSeconds}
72
+ resetsAt={metric.resetsAt}
73
+ />
74
+ </box>
75
+ <text fg="#6b7280">Resets: {metric.resetsIn}</text>
76
+ </box>
77
+ ))}
78
+ </box>
79
+ )}
80
+ </box>
81
+ );
82
+ }
@@ -0,0 +1,5 @@
1
+ export { Header } from "./Header";
2
+ export { Footer } from "./Footer";
3
+ export { Dashboard } from "./Dashboard";
4
+ export { ProviderCard } from "./ProviderCard";
5
+ export { ProgressBar } from "./ProgressBar";
package/src/index.tsx ADDED
@@ -0,0 +1,41 @@
1
+ import { createCliRenderer } from "@opentui/core";
2
+ import { createRoot } from "@opentui/react";
3
+ import { App } from "./App";
4
+
5
+ function resetTerminal() {
6
+ process.stdout.write("\x1b[?1049l");
7
+ process.stdout.write("\x1b[?25h");
8
+ process.stdout.write("\x1b[0m");
9
+ process.stdout.write("\x1b[?1000l");
10
+ process.stdout.write("\x1b[?1002l");
11
+ process.stdout.write("\x1b[?1003l");
12
+ process.stdout.write("\x1b[?1006l");
13
+ process.stdout.write("\x1b[?2004l");
14
+ }
15
+
16
+ async function main() {
17
+ const renderer = await createCliRenderer({
18
+ exitOnCtrlC: false,
19
+ });
20
+
21
+ const root = createRoot(renderer);
22
+
23
+ const cleanup = () => {
24
+ root.unmount();
25
+ renderer.stop();
26
+ resetTerminal();
27
+ process.exit(0);
28
+ };
29
+
30
+ process.on("SIGINT", cleanup);
31
+ process.on("SIGTERM", cleanup);
32
+ process.on("exit", resetTerminal);
33
+
34
+ root.render(<App onExit={cleanup} />);
35
+ renderer.start();
36
+ }
37
+
38
+ main().catch((err) => {
39
+ console.error("Fatal error:", err);
40
+ process.exit(1);
41
+ });
@@ -0,0 +1,102 @@
1
+ import { getClaudeCredentials } from "../utils/keychain";
2
+ import { timeUntil } from "../utils/time";
3
+ import type { ProviderStatus } from "./types";
4
+
5
+ interface ClaudeUsageResponse {
6
+ five_hour: { utilization: number; resets_at: string | null } | null;
7
+ seven_day: { utilization: number; resets_at: string | null } | null;
8
+ seven_day_opus: { utilization: number; resets_at: string | null } | null;
9
+ }
10
+
11
+ export async function fetchClaudeUsage(): Promise<ProviderStatus> {
12
+ const credentials = await getClaudeCredentials();
13
+
14
+ if (!credentials) {
15
+ return {
16
+ provider: "claude",
17
+ status: "unavailable",
18
+ metrics: [],
19
+ message: "Not logged in. Run 'claude' to authenticate.",
20
+ };
21
+ }
22
+
23
+ try {
24
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
25
+ method: "GET",
26
+ headers: {
27
+ Accept: "application/json",
28
+ "Content-Type": "application/json",
29
+ "User-Agent": "monitor/1.0.0",
30
+ Authorization: `Bearer ${credentials.accessToken}`,
31
+ "anthropic-beta": "oauth-2025-04-20",
32
+ },
33
+ });
34
+
35
+ if (!response.ok) {
36
+ if (response.status === 401) {
37
+ return {
38
+ provider: "claude",
39
+ status: "error",
40
+ metrics: [],
41
+ error: "Token expired. Run 'claude' to re-authenticate.",
42
+ };
43
+ }
44
+ return {
45
+ provider: "claude",
46
+ status: "error",
47
+ metrics: [],
48
+ error: `API error: ${response.status}`,
49
+ };
50
+ }
51
+
52
+ const data: ClaudeUsageResponse = await response.json();
53
+ const metrics = [];
54
+
55
+ if (data.five_hour) {
56
+ metrics.push({
57
+ name: "5-Hour",
58
+ percentage: data.five_hour.utilization,
59
+ resetsAt: data.five_hour.resets_at,
60
+ resetsIn: timeUntil(data.five_hour.resets_at),
61
+ periodSeconds: 5 * 3600,
62
+ });
63
+ }
64
+
65
+ if (data.seven_day) {
66
+ metrics.push({
67
+ name: "Weekly",
68
+ percentage: data.seven_day.utilization,
69
+ resetsAt: data.seven_day.resets_at,
70
+ resetsIn: timeUntil(data.seven_day.resets_at),
71
+ periodSeconds: 7 * 24 * 3600,
72
+ });
73
+ }
74
+
75
+ if (data.seven_day_opus && data.seven_day_opus.utilization > 0) {
76
+ metrics.push({
77
+ name: "Opus",
78
+ percentage: data.seven_day_opus.utilization,
79
+ resetsAt: data.seven_day_opus.resets_at,
80
+ resetsIn: timeUntil(data.seven_day_opus.resets_at),
81
+ periodSeconds: 7 * 24 * 3600,
82
+ });
83
+ }
84
+
85
+ const maxUsage = Math.max(...metrics.map((m) => m.percentage), 0);
86
+ const status = maxUsage >= 80 ? "warning" : "ok";
87
+
88
+ return {
89
+ provider: "claude",
90
+ status,
91
+ plan: credentials.subscriptionType || "Pro",
92
+ metrics,
93
+ };
94
+ } catch (err) {
95
+ return {
96
+ provider: "claude",
97
+ status: "error",
98
+ metrics: [],
99
+ error: err instanceof Error ? err.message : "Unknown error",
100
+ };
101
+ }
102
+ }
@@ -0,0 +1,113 @@
1
+ import { getCodexCredentials } from "../utils/keychain";
2
+ import { formatDuration } from "../utils/time";
3
+ import type { ProviderStatus } from "./types";
4
+
5
+ interface RateLimitWindow {
6
+ used_percent: number;
7
+ limit_window_seconds: number;
8
+ reset_after_seconds: number;
9
+ reset_at: number;
10
+ }
11
+
12
+ interface CodexUsageResponse {
13
+ plan_type: string;
14
+ rate_limit?: {
15
+ allowed: boolean;
16
+ limit_reached: boolean;
17
+ primary_window?: RateLimitWindow;
18
+ secondary_window?: RateLimitWindow;
19
+ };
20
+ }
21
+
22
+ export async function fetchCodexUsage(): Promise<ProviderStatus> {
23
+ const credentials = await getCodexCredentials();
24
+
25
+ if (!credentials) {
26
+ return {
27
+ provider: "codex",
28
+ status: "unavailable",
29
+ metrics: [],
30
+ message: "Not logged in. Run 'codex' to authenticate.",
31
+ };
32
+ }
33
+
34
+ try {
35
+ const response = await fetch(
36
+ "https://chatgpt.com/backend-api/wham/usage",
37
+ {
38
+ headers: {
39
+ Authorization: `Bearer ${credentials.accessToken}`,
40
+ "ChatGPT-Account-Id": credentials.accountId,
41
+ "originator": "codex_cli_rs",
42
+ "User-Agent": "codex_cli_rs/0.77.0",
43
+ },
44
+ }
45
+ );
46
+
47
+ if (!response.ok) {
48
+ if (response.status === 401) {
49
+ return {
50
+ provider: "codex",
51
+ status: "error",
52
+ metrics: [],
53
+ message: "Token expired. Run 'codex' to re-authenticate.",
54
+ };
55
+ }
56
+ return {
57
+ provider: "codex",
58
+ status: "error",
59
+ metrics: [],
60
+ message: `API error: ${response.status}`,
61
+ };
62
+ }
63
+
64
+ const data: CodexUsageResponse = await response.json();
65
+ const metrics = [];
66
+
67
+ if (data.rate_limit?.primary_window) {
68
+ const primary = data.rate_limit.primary_window;
69
+ const windowHours = Math.round(primary.limit_window_seconds / 3600);
70
+ const resetMs = primary.reset_after_seconds * 1000;
71
+ metrics.push({
72
+ name: `${windowHours}h Usage`,
73
+ percentage: primary.used_percent,
74
+ resetsAt: new Date(primary.reset_at * 1000).toISOString(),
75
+ resetsIn: formatDuration(resetMs),
76
+ periodSeconds: primary.limit_window_seconds,
77
+ });
78
+ }
79
+
80
+ if (data.rate_limit?.secondary_window) {
81
+ const secondary = data.rate_limit.secondary_window;
82
+ const windowDays = Math.round(secondary.limit_window_seconds / 86400);
83
+ const resetMs = secondary.reset_after_seconds * 1000;
84
+ metrics.push({
85
+ name: windowDays >= 7 ? "Weekly" : `${windowDays}d Usage`,
86
+ percentage: secondary.used_percent,
87
+ resetsAt: new Date(secondary.reset_at * 1000).toISOString(),
88
+ resetsIn: formatDuration(resetMs),
89
+ periodSeconds: secondary.limit_window_seconds,
90
+ });
91
+ }
92
+
93
+ const planLabel = data.plan_type
94
+ ? data.plan_type.charAt(0).toUpperCase() + data.plan_type.slice(1)
95
+ : "Unknown";
96
+
97
+ const maxUsage = Math.max(...metrics.map((m) => m.percentage), 0);
98
+
99
+ return {
100
+ provider: "codex",
101
+ status: data.rate_limit?.limit_reached || maxUsage >= 80 ? "warning" : "ok",
102
+ metrics,
103
+ plan: planLabel,
104
+ };
105
+ } catch (error) {
106
+ return {
107
+ provider: "codex",
108
+ status: "error",
109
+ metrics: [],
110
+ message: error instanceof Error ? error.message : "Failed to fetch usage",
111
+ };
112
+ }
113
+ }
@@ -0,0 +1,63 @@
1
+ import { getGeminiSettings } from "../utils/keychain";
2
+ import type { ProviderStatus, UsageMetric } from "./types";
3
+
4
+ interface GeminiLimits {
5
+ requestsPerDay: number;
6
+ requestsPerMinute: number;
7
+ plan: string;
8
+ }
9
+
10
+ function getLimitsForAuthType(authType?: string): GeminiLimits {
11
+ switch (authType) {
12
+ case "google":
13
+ return { requestsPerDay: 1000, requestsPerMinute: 60, plan: "Google (Free)" };
14
+ case "api_key":
15
+ return { requestsPerDay: 250, requestsPerMinute: 10, plan: "API Key (Free)" };
16
+ case "vertex":
17
+ return { requestsPerDay: -1, requestsPerMinute: -1, plan: "Vertex AI" };
18
+ default:
19
+ return { requestsPerDay: 1000, requestsPerMinute: 60, plan: "Unknown" };
20
+ }
21
+ }
22
+
23
+ export async function fetchGeminiUsage(): Promise<ProviderStatus> {
24
+ const settings = await getGeminiSettings();
25
+
26
+ if (!settings) {
27
+ return {
28
+ provider: "gemini",
29
+ status: "unavailable",
30
+ metrics: [],
31
+ message: "Not configured. Run 'gemini' to set up.",
32
+ };
33
+ }
34
+
35
+ const limits = getLimitsForAuthType(settings.authType);
36
+ const metrics: UsageMetric[] = [];
37
+
38
+ if (limits.requestsPerDay > 0) {
39
+ metrics.push({
40
+ name: "Daily Limit",
41
+ percentage: -1,
42
+ resetsAt: null,
43
+ resetsIn: `${limits.requestsPerDay} req/day`,
44
+ });
45
+ }
46
+
47
+ if (limits.requestsPerMinute > 0) {
48
+ metrics.push({
49
+ name: "Per-Minute",
50
+ percentage: -1,
51
+ resetsAt: null,
52
+ resetsIn: `${limits.requestsPerMinute} req/min`,
53
+ });
54
+ }
55
+
56
+ return {
57
+ provider: "gemini",
58
+ status: "limited",
59
+ plan: limits.plan,
60
+ metrics,
61
+ message: "Live usage not available. Run /stats in Gemini CLI.",
62
+ };
63
+ }
@@ -0,0 +1,19 @@
1
+ export * from "./types";
2
+ export { fetchClaudeUsage } from "./claude";
3
+ export { fetchCodexUsage } from "./codex";
4
+ export { fetchGeminiUsage } from "./gemini";
5
+
6
+ import { fetchClaudeUsage } from "./claude";
7
+ import { fetchCodexUsage } from "./codex";
8
+ import { fetchGeminiUsage } from "./gemini";
9
+ import type { ProviderStatus } from "./types";
10
+
11
+ export async function fetchAllProviders(): Promise<ProviderStatus[]> {
12
+ const [claude, codex, gemini] = await Promise.all([
13
+ fetchClaudeUsage(),
14
+ fetchCodexUsage(),
15
+ fetchGeminiUsage(),
16
+ ]);
17
+
18
+ return [claude, codex, gemini];
19
+ }
@@ -0,0 +1,24 @@
1
+ export type ProviderName = "claude" | "codex" | "gemini";
2
+
3
+ export type ProviderStatusType = "ok" | "warning" | "error" | "unavailable" | "loading" | "limited";
4
+
5
+ export interface UsageMetric {
6
+ name: string;
7
+ percentage: number;
8
+ resetsAt: string | null;
9
+ resetsIn: string;
10
+ periodSeconds?: number; // Total window duration for trajectory calculation
11
+ }
12
+
13
+ export interface ProviderStatus {
14
+ provider: ProviderName;
15
+ status: ProviderStatusType;
16
+ plan?: string;
17
+ metrics: UsageMetric[];
18
+ message?: string;
19
+ error?: string;
20
+ }
21
+
22
+ export interface ProviderFetcher {
23
+ fetch(): Promise<ProviderStatus>;
24
+ }
@@ -0,0 +1,31 @@
1
+ export function getUsageColor(percentage: number): string {
2
+ if (percentage < 50) return "#22c55e";
3
+ if (percentage < 80) return "#eab308";
4
+ return "#ef4444";
5
+ }
6
+
7
+ export function getStatusColor(status: string): string {
8
+ switch (status) {
9
+ case "ok": return "#22c55e";
10
+ case "warning": return "#eab308";
11
+ case "error": return "#ef4444";
12
+ case "unavailable": return "#6b7280";
13
+ case "limited": return "#3b82f6";
14
+ default: return "#9ca3af";
15
+ }
16
+ }
17
+
18
+ export const PROVIDER_COLORS = {
19
+ claude: "#d97706",
20
+ codex: "#10b981",
21
+ gemini: "#3b82f6",
22
+ } as const;
23
+
24
+ export const UI_COLORS = {
25
+ background: "#1a1a2e",
26
+ surface: "#16213e",
27
+ border: "#0f3460",
28
+ text: "#e5e5e5",
29
+ textMuted: "#9ca3af",
30
+ textDim: "#6b7280",
31
+ } as const;
@@ -0,0 +1,106 @@
1
+ import { $ } from "bun";
2
+
3
+ export async function getKeychainCredentials(service: string): Promise<string | null> {
4
+ try {
5
+ const result = await $`security find-generic-password -s ${service} -w`
6
+ .quiet()
7
+ .text();
8
+ return result.trim();
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ export interface ClaudeCredentials {
15
+ accessToken: string;
16
+ refreshToken: string;
17
+ expiresAt: number;
18
+ scopes: string[];
19
+ subscriptionType: string;
20
+ }
21
+
22
+ export async function getClaudeCredentials(): Promise<ClaudeCredentials | null> {
23
+ try {
24
+ const raw = await getKeychainCredentials("Claude Code-credentials");
25
+ if (!raw) return null;
26
+
27
+ const parsed = JSON.parse(raw);
28
+ return parsed.claudeAiOauth || null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export interface CodexCredentials {
35
+ accessToken: string;
36
+ accountId: string;
37
+ planType?: string;
38
+ }
39
+
40
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
41
+ try {
42
+ const parts = token.split(".");
43
+ if (parts.length !== 3) return null;
44
+ const payload = Buffer.from(parts[1], "base64").toString("utf-8");
45
+ return JSON.parse(payload);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function getCodexCredentials(): Promise<CodexCredentials | null> {
52
+ try {
53
+ const homedir = process.env.HOME || "~";
54
+ const file = Bun.file(`${homedir}/.codex/auth.json`);
55
+
56
+ if (!(await file.exists())) {
57
+ return null;
58
+ }
59
+
60
+ const content = await file.json();
61
+ const tokens = content.tokens;
62
+
63
+ if (!tokens?.access_token || !tokens?.account_id) {
64
+ return null;
65
+ }
66
+
67
+ let planType: string | undefined;
68
+ const payload = decodeJwtPayload(tokens.access_token);
69
+ if (payload) {
70
+ const auth = payload["https://api.openai.com/auth"] as Record<string, unknown> | undefined;
71
+ if (auth?.chatgpt_plan_type) {
72
+ planType = String(auth.chatgpt_plan_type);
73
+ }
74
+ }
75
+
76
+ return {
77
+ accessToken: tokens.access_token,
78
+ accountId: tokens.account_id,
79
+ planType,
80
+ };
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export interface GeminiSettings {
87
+ authType?: "google" | "api_key" | "vertex";
88
+ apiKey?: string;
89
+ project?: string;
90
+ }
91
+
92
+ export async function getGeminiSettings(): Promise<GeminiSettings | null> {
93
+ try {
94
+ const homedir = process.env.HOME || "~";
95
+ const file = Bun.file(`${homedir}/.gemini/settings.json`);
96
+
97
+ if (!(await file.exists())) {
98
+ return null;
99
+ }
100
+
101
+ const content = await file.json();
102
+ return content;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
@@ -0,0 +1,94 @@
1
+ export function formatDuration(ms: number): string {
2
+ if (ms < 0) return "now";
3
+
4
+ const seconds = Math.floor(ms / 1000);
5
+ const minutes = Math.floor(seconds / 60);
6
+ const hours = Math.floor(minutes / 60);
7
+ const days = Math.floor(hours / 24);
8
+
9
+ if (days > 0) {
10
+ const remainingHours = hours % 24;
11
+ if (remainingHours > 0) {
12
+ return `${days}d ${remainingHours}h`;
13
+ }
14
+ return `${days}d`;
15
+ }
16
+
17
+ if (hours > 0) {
18
+ const remainingMinutes = minutes % 60;
19
+ if (remainingMinutes > 0) {
20
+ return `${hours}h ${remainingMinutes}m`;
21
+ }
22
+ return `${hours}h`;
23
+ }
24
+
25
+ if (minutes > 0) {
26
+ return `${minutes}m`;
27
+ }
28
+
29
+ return `${seconds}s`;
30
+ }
31
+
32
+ export function timeUntil(isoDate: string | null): string {
33
+ if (!isoDate) return "unknown";
34
+
35
+ try {
36
+ const target = new Date(isoDate).getTime();
37
+ const now = Date.now();
38
+ const diff = target - now;
39
+
40
+ if (diff <= 0) return "now";
41
+ return formatDuration(diff);
42
+ } catch {
43
+ return "unknown";
44
+ }
45
+ }
46
+
47
+ export function formatDate(isoDate: string | null): string {
48
+ if (!isoDate) return "unknown";
49
+
50
+ try {
51
+ const date = new Date(isoDate);
52
+ const now = new Date();
53
+
54
+ const isSameDay = date.toDateString() === now.toDateString();
55
+ if (isSameDay) {
56
+ return date.toLocaleTimeString("en-US", {
57
+ hour: "numeric",
58
+ minute: "2-digit",
59
+ hour12: true,
60
+ });
61
+ }
62
+
63
+ const daysDiff = Math.floor((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
64
+ const isWithinWeek = daysDiff >= 0 && daysDiff < 7;
65
+ if (isWithinWeek) {
66
+ return date.toLocaleDateString("en-US", {
67
+ weekday: "short",
68
+ hour: "numeric",
69
+ minute: "2-digit",
70
+ hour12: true,
71
+ });
72
+ }
73
+
74
+ return date.toLocaleDateString("en-US", {
75
+ month: "short",
76
+ day: "numeric",
77
+ hour: "numeric",
78
+ minute: "2-digit",
79
+ });
80
+ } catch {
81
+ return "unknown";
82
+ }
83
+ }
84
+
85
+ export function timeAgo(date: Date): string {
86
+ const now = Date.now();
87
+ const diff = now - date.getTime();
88
+
89
+ if (diff < 1000) return "just now";
90
+ if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
91
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
92
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
93
+ return `${Math.floor(diff / 86400000)}d ago`;
94
+ }