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 +128 -0
- package/bin/cli.tsx +61 -0
- package/package.json +61 -0
- package/src/App.tsx +64 -0
- package/src/components/Dashboard.tsx +23 -0
- package/src/components/Footer.tsx +19 -0
- package/src/components/Header.tsx +41 -0
- package/src/components/ProgressBar.tsx +81 -0
- package/src/components/ProviderCard.tsx +82 -0
- package/src/components/index.ts +5 -0
- package/src/index.tsx +41 -0
- package/src/providers/claude.ts +102 -0
- package/src/providers/codex.ts +113 -0
- package/src/providers/gemini.ts +63 -0
- package/src/providers/index.ts +19 -0
- package/src/providers/types.ts +24 -0
- package/src/utils/colors.ts +31 -0
- package/src/utils/keychain.ts +106 -0
- package/src/utils/time.ts +94 -0
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
|
+
}
|
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
|
+
}
|