@miraigent/codex-usage-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miraigent
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Codex Usage Dashboard
2
+
3
+ Local web dashboard for viewing Codex account usage limits across multiple CODEX_HOME profiles.
4
+
5
+ This is an unofficial tool. It is not made by, affiliated with, endorsed by, or supported by OpenAI.
6
+
7
+ ## What It Shows
8
+
9
+ - Codex account connection state
10
+ - 5-hour usage window
11
+ - Weekly usage window
12
+ - Reset time
13
+ - Remaining credit balance when returned by Codex
14
+ - Re-login device code helper for each configured account
15
+ - One account or any number of accounts, driven by the accounts array in config.json
16
+
17
+ The dashboard does not print or store ChatGPT/Codex tokens. It starts codex app-server --stdio and calls account/read and account/rateLimits/read.
18
+
19
+ ## Requirements
20
+
21
+ - Node.js 20 or newer
22
+ - Python 3 with the standard library pty module
23
+ - OpenAI Codex CLI installed and available as codex
24
+ - A local machine or server where you can log in with Codex
25
+
26
+ ## Install
27
+
28
+ npm install -g @miraigent/codex-usage-dashboard
29
+
30
+ Or run from a cloned repository:
31
+
32
+ npm install
33
+ npm start
34
+
35
+ ## Configure
36
+
37
+ Create a config file. Use one account, two accounts, or any number of accounts:
38
+
39
+ mkdir -p ~/.codex-accounts/codex-1 ~/.codex-accounts/codex-2
40
+ cp config.example.json config.json
41
+
42
+ Edit config.json:
43
+
44
+ {
45
+ "host": "127.0.0.1",
46
+ "port": 8787,
47
+ "refreshSeconds": 60,
48
+ "accounts": [
49
+ {
50
+ "id": "codex-1",
51
+ "label": "Codex Account 1",
52
+ "codexCommand": "codex",
53
+ "codexHome": "/absolute/path/to/.codex-accounts/codex-1"
54
+ },
55
+ {
56
+ "id": "codex-2",
57
+ "label": "Codex Account 2",
58
+ "codexCommand": "codex",
59
+ "codexHome": "/absolute/path/to/.codex-accounts/codex-2"
60
+ }
61
+ ]
62
+ }
63
+
64
+ You can also point to a config file explicitly:
65
+
66
+ CODEX_USAGE_DASHBOARD_CONFIG=/path/to/config.json codex-usage-dashboard
67
+
68
+ ## Login
69
+
70
+ Use the dashboard's re-login panel, or run Codex login manually for each profile:
71
+
72
+ CODEX_HOME=/absolute/path/to/.codex-accounts/codex-1 codex login --device-auth
73
+ CODEX_HOME=/absolute/path/to/.codex-accounts/codex-2 codex login --device-auth
74
+
75
+ For a single-account setup, keep only one object in accounts.
76
+
77
+ For three or more accounts, add more objects with unique id and codexHome values.
78
+
79
+ ## Run
80
+
81
+ codex-usage-dashboard
82
+
83
+ Open:
84
+
85
+ http://127.0.0.1:8787
86
+
87
+ ## Basic Authentication
88
+
89
+ Set both variables to protect the dashboard:
90
+
91
+ export CODEX_USAGE_DASHBOARD_BASIC_USER='your-user'
92
+ export CODEX_USAGE_DASHBOARD_BASIC_PASSWORD='your-password'
93
+ codex-usage-dashboard
94
+
95
+ Do not expose this dashboard to the public internet without authentication, a VPN, or a trusted reverse proxy.
96
+
97
+ ## Re-login Flow
98
+
99
+ When auth expires:
100
+
101
+ 1. Open the dashboard.
102
+ 2. Click codex-1 or codex-2 under Re-login.
103
+ 3. Open the displayed OpenAI device login link.
104
+ 4. Enter the displayed one-time code.
105
+ 5. Click Refresh after login completes.
106
+
107
+ The re-login helper logs out only the selected CODEX_HOME profile before generating a new device code.
108
+
109
+ ## Security Notes
110
+
111
+ - Keep config.json private if it contains local paths you do not want to publish.
112
+ - Never paste ChatGPT/Codex tokens into this app.
113
+ - Do not commit auth.json or any CODEX_HOME directory.
114
+ - Bind to 127.0.0.1 unless you have an authenticated access layer.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../server");
@@ -0,0 +1,19 @@
1
+ {
2
+ "host": "127.0.0.1",
3
+ "port": 8787,
4
+ "refreshSeconds": 60,
5
+ "accounts": [
6
+ {
7
+ "id": "codex-1",
8
+ "label": "Codex Account 1",
9
+ "codexCommand": "codex",
10
+ "codexHome": "/absolute/path/to/.codex-accounts/codex-1"
11
+ },
12
+ {
13
+ "id": "codex-2",
14
+ "label": "Codex Account 2",
15
+ "codexCommand": "codex",
16
+ "codexHome": "/absolute/path/to/.codex-accounts/codex-2"
17
+ }
18
+ ]
19
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@miraigent/codex-usage-dashboard",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Local dashboard for Codex account usage limits across multiple CODEX_HOME profiles.",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "codex-usage-dashboard": "bin/codex-usage-dashboard"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.js",
12
+ "check": "node --check server.js && node --check public/app.js && python3 -m py_compile scripts/codex-device-code.py",
13
+ "pack:dry-run": "npm pack --dry-run"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "public/",
18
+ "scripts/codex-device-code.py",
19
+ "scripts/check-accounts.sh",
20
+ "scripts/login-account.sh",
21
+ "config.example.json",
22
+ "server.js",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/Miraigent/codex-usage-dashboard.git"
29
+ },
30
+ "keywords": [
31
+ "codex",
32
+ "openai",
33
+ "usage",
34
+ "dashboard",
35
+ "quota"
36
+ ],
37
+ "author": "Miraigent",
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ }
45
+ }
package/public/app.js ADDED
@@ -0,0 +1,183 @@
1
+ const accountsEl = document.querySelector("#accounts");
2
+ const statusEl = document.querySelector("#status");
3
+ const refreshEl = document.querySelector("#refresh");
4
+ const loginResultEl = document.querySelector("#login-result");
5
+ const loginActionsEl = document.querySelector(".login-actions");
6
+
7
+ let refreshTimer = null;
8
+ let renderedLoginAccounts = "";
9
+
10
+ function fmtDate(value) {
11
+ if (!value) return "未開始または不明";
12
+ const ms = typeof value === "number" ? value * 1000 : Date.parse(value);
13
+ if (!Number.isFinite(ms)) return "不明";
14
+ return new Intl.DateTimeFormat("ja-JP", {
15
+ month: "2-digit",
16
+ day: "2-digit",
17
+ hour: "2-digit",
18
+ minute: "2-digit",
19
+ }).format(new Date(ms));
20
+ }
21
+
22
+ function fmtUntil(value) {
23
+ if (!value) return "不明";
24
+ const ms = typeof value === "number" ? value * 1000 : Date.parse(value);
25
+ if (!Number.isFinite(ms)) return "不明";
26
+ const remainingMs = ms - Date.now();
27
+ if (remainingMs <= 0) return "まもなく更新";
28
+ const totalMinutes = Math.ceil(remainingMs / 60000);
29
+ const days = Math.floor(totalMinutes / 1440);
30
+ const hours = Math.floor((totalMinutes % 1440) / 60);
31
+ const minutes = totalMinutes % 60;
32
+ if (days > 0) return "あと" + days + "日" + hours + "時間";
33
+ if (hours > 0) return "あと" + hours + "時間" + minutes + "分";
34
+ return "あと" + minutes + "分";
35
+ }
36
+
37
+ function pct(value) {
38
+ return typeof value === "number" ? Math.round(value * 10) / 10 : null;
39
+ }
40
+
41
+ function windowName(window, fallback) {
42
+ if (!window?.windowDurationMins) return fallback;
43
+ if (window.windowDurationMins === 300) return "5時間の使用制限";
44
+ if (window.windowDurationMins === 10080) return "週間利用上限";
45
+ return window.windowDurationMins + "分の使用制限";
46
+ }
47
+
48
+ function limitView(title, window) {
49
+ if (!window) return "";
50
+ const used = pct(window.usedPercent) ?? 0;
51
+ const remaining = pct(window.remainingPercent) ?? Math.max(0, 100 - used);
52
+ const cls = remaining <= 5 ? "bad" : remaining <= 15 ? "warn" : "";
53
+ return [
54
+ '<div class="limit">',
55
+ '<div class="limit-title"><span>' + escapeHtml(title) + '</span><span>' + remaining + '% 残り</span></div>',
56
+ '<div class="bar"><span class="' + cls + '" style="width:' + Math.max(0, Math.min(100, remaining)) + '%"></span></div>',
57
+ '<div class="details">',
58
+ '<span>使用済み</span><span>' + used + '%</span>',
59
+ '<span>リセット</span><span>' + escapeHtml(fmtDate(window.resetsAt)) + '</span>',
60
+ '<span>解除まで</span><span>' + escapeHtml(fmtUntil(window.resetsAt)) + '</span>',
61
+ '</div>',
62
+ '</div>',
63
+ ].join("");
64
+ }
65
+
66
+ function accountView(account) {
67
+ if (!account.ok) {
68
+ return [
69
+ '<article class="card error-card">',
70
+ '<h2>' + escapeHtml(account.label || account.id) + '</h2>',
71
+ '<p class="meta">取得失敗 / 再ログインが必要です</p>',
72
+ '<p class="error">' + escapeHtml(account.error || "Unknown error") + '</p>',
73
+ '</article>',
74
+ ].join("");
75
+ }
76
+
77
+ const limit = account.rateLimits || {};
78
+ const plan = account.account?.planType || limit.planType || "plan unknown";
79
+ const credits = limit.credits
80
+ ? '<p class="credits">クレジット: ' + escapeHtml(String(limit.credits.balance ?? "0")) + (limit.credits.unlimited ? " / unlimited" : "") + "</p>"
81
+ : "";
82
+
83
+ return [
84
+ '<article class="card">',
85
+ '<h2>' + escapeHtml(account.label || account.id) + '</h2>',
86
+ '<p class="meta">' + escapeHtml(plan) + ' / ' + (account.account?.hasEmail ? "account connected" : "email hidden") + '</p>',
87
+ limitView(windowName(limit.primary, "5時間の使用制限"), limit.primary),
88
+ limitView(windowName(limit.secondary, "週間利用上限"), limit.secondary),
89
+ credits,
90
+ '</article>',
91
+ ].join("");
92
+ }
93
+
94
+ function renderLoginActions(accounts) {
95
+ const key = accounts.map((account) => account.id + ":" + (account.label || "")).join("|");
96
+ if (key === renderedLoginAccounts) return;
97
+ renderedLoginAccounts = key;
98
+ if (!accounts.length) {
99
+ loginActionsEl.innerHTML = '<span class="meta">アカウントが未設定です。</span>';
100
+ return;
101
+ }
102
+ loginActionsEl.innerHTML = accounts
103
+ .map((account) => {
104
+ const id = escapeHtml(account.id);
105
+ const label = escapeHtml(account.label || account.id);
106
+ return '<button class="login-code" type="button" data-account="' + id + '">' + label + "</button>";
107
+ })
108
+ .join("");
109
+ for (const button of loginActionsEl.querySelectorAll(".login-code")) {
110
+ button.addEventListener("click", () => createLoginCode(button.dataset.account, button));
111
+ }
112
+ }
113
+
114
+ function escapeHtml(value) {
115
+ return String(value)
116
+ .replaceAll("&", "&amp;")
117
+ .replaceAll("<", "&lt;")
118
+ .replaceAll(">", "&gt;")
119
+ .replaceAll('"', "&quot;")
120
+ .replaceAll("'", "&#039;");
121
+ }
122
+
123
+ async function load() {
124
+ refreshEl.disabled = true;
125
+ statusEl.textContent = "Refreshing...";
126
+ try {
127
+ const response = await fetch("api/usage", { cache: "no-store" });
128
+ const data = await response.json();
129
+ if (!data.ok && data.error) {
130
+ statusEl.textContent = data.error;
131
+ } else {
132
+ statusEl.textContent = "最終更新: " + fmtDate(data.fetchedAt);
133
+ }
134
+ const accounts = data.accounts || [];
135
+ renderLoginActions(accounts);
136
+ accountsEl.innerHTML = accounts.map(accountView).join("") || '<p class="status">アカウントが未設定です。</p>';
137
+ if (refreshTimer) clearTimeout(refreshTimer);
138
+ const seconds = Math.max(30, data.refreshSeconds || 60);
139
+ refreshTimer = setTimeout(load, seconds * 1000);
140
+ } catch (error) {
141
+ statusEl.textContent = "取得失敗: " + error.message;
142
+ } finally {
143
+ refreshEl.disabled = false;
144
+ }
145
+ }
146
+
147
+ refreshEl.addEventListener("click", load);
148
+ loadConfiguredAccounts();
149
+ load();
150
+
151
+ async function loadConfiguredAccounts() {
152
+ try {
153
+ const response = await fetch("api/accounts", { cache: "no-store" });
154
+ const data = await response.json();
155
+ if (data.ok) renderLoginActions(data.accounts || []);
156
+ } catch {
157
+ loginActionsEl.innerHTML = '<span class="meta">アカウント一覧を取得できませんでした。</span>';
158
+ }
159
+ }
160
+
161
+ async function createLoginCode(account, button) {
162
+ button.disabled = true;
163
+ loginResultEl.hidden = false;
164
+ loginResultEl.innerHTML = '<p class="meta">ログインコードを発行中です...</p>';
165
+ try {
166
+ const response = await fetch("api/login-code?account=" + encodeURIComponent(account), { cache: "no-store" });
167
+ const data = await response.json();
168
+ if (!data.ok) {
169
+ loginResultEl.innerHTML = '<p class="error">' + escapeHtml(data.error || "ログインコードを発行できませんでした") + "</p>";
170
+ return;
171
+ }
172
+ loginResultEl.innerHTML = [
173
+ '<p class="meta">' + escapeHtml(data.label) + ' / ' + escapeHtml(data.generatedAt) + '</p>',
174
+ '<p><a href="' + escapeHtml(data.url) + '" target="_blank" rel="noreferrer">OpenAI Codex device loginを開く</a></p>',
175
+ '<p class="login-code-value">' + escapeHtml(data.code) + '</p>',
176
+ '<p class="meta">このコードを開いたページに入力してください。完了後、Refreshで残量を確認できます。</p>',
177
+ ].join("");
178
+ } catch (error) {
179
+ loginResultEl.innerHTML = '<p class="error">取得失敗: ' + escapeHtml(error.message) + "</p>";
180
+ } finally {
181
+ button.disabled = false;
182
+ }
183
+ }
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Codex Usage Dashboard</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <header>
12
+ <div>
13
+ <p class="eyebrow">Codex Usage</p>
14
+ <h1>Account Limits</h1>
15
+ </div>
16
+ <button id="refresh" type="button">Refresh</button>
17
+ </header>
18
+ <p id="status" class="status">Loading...</p>
19
+ <section class="login-panel">
20
+ <div>
21
+ <p class="eyebrow">Codex Login</p>
22
+ <h2>再ログインコード発行</h2>
23
+ <p class="meta">認証が切れた時だけ使います。対象アカウントを一度ログアウトして、新しい15分コードを発行します。</p>
24
+ </div>
25
+ <div class="login-actions">
26
+ <span id="login-actions-status" class="meta">Loading accounts...</span>
27
+ </div>
28
+ <div id="login-result" class="login-result" hidden></div>
29
+ </section>
30
+ <section id="accounts" class="accounts"></section>
31
+ </main>
32
+ <script src="app.js"></script>
33
+ </body>
34
+ </html>
@@ -0,0 +1,201 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #f6f7f9;
4
+ --panel: #ffffff;
5
+ --text: #172033;
6
+ --muted: #667085;
7
+ --line: #d7dce5;
8
+ --good: #0f8a5f;
9
+ --warn: #b76e00;
10
+ --bad: #b42318;
11
+ --accent: #176b87;
12
+ }
13
+
14
+ @media (prefers-color-scheme: dark) {
15
+ :root {
16
+ --bg: #101418;
17
+ --panel: #181e25;
18
+ --text: #edf2f7;
19
+ --muted: #a6b0bd;
20
+ --line: #2d3642;
21
+ --accent: #6fc2d0;
22
+ }
23
+ }
24
+
25
+ * {
26
+ box-sizing: border-box;
27
+ }
28
+
29
+ body {
30
+ margin: 0;
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
34
+ }
35
+
36
+ main {
37
+ width: min(1120px, calc(100vw - 32px));
38
+ margin: 32px auto;
39
+ }
40
+
41
+ header {
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ gap: 16px;
46
+ margin-bottom: 18px;
47
+ }
48
+
49
+ .eyebrow {
50
+ color: var(--muted);
51
+ font-size: 13px;
52
+ margin: 0 0 4px;
53
+ }
54
+
55
+ h1 {
56
+ font-size: 32px;
57
+ line-height: 1.1;
58
+ margin: 0;
59
+ }
60
+
61
+ button {
62
+ border: 1px solid var(--line);
63
+ background: var(--panel);
64
+ color: var(--text);
65
+ border-radius: 6px;
66
+ padding: 9px 14px;
67
+ font: inherit;
68
+ cursor: pointer;
69
+ }
70
+
71
+ .status {
72
+ color: var(--muted);
73
+ min-height: 24px;
74
+ }
75
+
76
+ .accounts {
77
+ display: grid;
78
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
79
+ gap: 16px;
80
+ }
81
+
82
+ .login-panel {
83
+ background: var(--panel);
84
+ border: 1px solid var(--line);
85
+ border-radius: 8px;
86
+ padding: 18px;
87
+ margin: 0 0 16px;
88
+ }
89
+
90
+ .login-panel h2 {
91
+ margin: 0 0 6px;
92
+ font-size: 20px;
93
+ }
94
+
95
+ .login-actions {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ gap: 10px;
99
+ margin-top: 12px;
100
+ }
101
+
102
+ .login-result {
103
+ border-top: 1px solid var(--line);
104
+ margin-top: 14px;
105
+ padding-top: 14px;
106
+ }
107
+
108
+ .login-result a {
109
+ color: var(--accent);
110
+ }
111
+
112
+ .login-code-value {
113
+ display: inline-block;
114
+ letter-spacing: 0.08em;
115
+ font-size: 24px;
116
+ font-weight: 750;
117
+ border: 1px solid var(--line);
118
+ border-radius: 6px;
119
+ padding: 8px 12px;
120
+ margin: 4px 0;
121
+ }
122
+
123
+ .card {
124
+ background: var(--panel);
125
+ border: 1px solid var(--line);
126
+ border-radius: 8px;
127
+ padding: 18px;
128
+ }
129
+
130
+ .card h2 {
131
+ margin: 0 0 6px;
132
+ font-size: 20px;
133
+ }
134
+
135
+ .meta {
136
+ color: var(--muted);
137
+ font-size: 13px;
138
+ margin: 0 0 14px;
139
+ }
140
+
141
+ .error {
142
+ color: var(--bad);
143
+ white-space: pre-wrap;
144
+ }
145
+
146
+ .error-card {
147
+ border-color: color-mix(in srgb, var(--bad) 55%, var(--line));
148
+ }
149
+
150
+ .limit {
151
+ border-top: 1px solid var(--line);
152
+ padding-top: 14px;
153
+ margin-top: 14px;
154
+ }
155
+
156
+ .limit-title {
157
+ display: flex;
158
+ justify-content: space-between;
159
+ gap: 10px;
160
+ font-weight: 650;
161
+ margin-bottom: 8px;
162
+ }
163
+
164
+ .bar {
165
+ width: 100%;
166
+ height: 10px;
167
+ overflow: hidden;
168
+ background: var(--bg);
169
+ border: 1px solid var(--line);
170
+ border-radius: 999px;
171
+ }
172
+
173
+ .bar span {
174
+ display: block;
175
+ height: 100%;
176
+ width: 0;
177
+ background: var(--good);
178
+ }
179
+
180
+ .bar span.warn {
181
+ background: var(--warn);
182
+ }
183
+
184
+ .bar span.bad {
185
+ background: var(--bad);
186
+ }
187
+
188
+ .details {
189
+ display: grid;
190
+ grid-template-columns: 1fr 1fr;
191
+ gap: 8px 12px;
192
+ color: var(--muted);
193
+ font-size: 13px;
194
+ margin-top: 10px;
195
+ }
196
+
197
+ .credits {
198
+ margin-top: 14px;
199
+ color: var(--muted);
200
+ font-size: 14px;
201
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [ "$#" -gt 0 ]; then
5
+ accounts=("$@")
6
+ elif [ -n "${CODEX_USAGE_ACCOUNTS:-}" ]; then
7
+ read -r -a accounts <<< "$CODEX_USAGE_ACCOUNTS"
8
+ else
9
+ accounts=(codex-1 codex-2)
10
+ fi
11
+
12
+ for account in "${accounts[@]}"; do
13
+ accounts_dir="${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}"
14
+ export CODEX_HOME="$accounts_dir/$account"
15
+ echo "== $account =="
16
+ if [ ! -d "$CODEX_HOME" ]; then
17
+ echo "missing CODEX_HOME: $CODEX_HOME"
18
+ continue
19
+ fi
20
+ codex login status 2>&1 | sed -E 's/[A-Za-z0-9_=-]{24,}/<redacted>/g' || true
21
+ done
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import pty
5
+ import re
6
+ import select
7
+ import subprocess
8
+ import sys
9
+ import time
10
+
11
+
12
+ def main() -> int:
13
+ if len(sys.argv) != 4:
14
+ print(json.dumps({"ok": False, "error": "usage: codex-device-code.py <account-id> <codex-command> <codex-home>"}))
15
+ return 2
16
+
17
+ account_id, codex_command, codex_home = sys.argv[1:4]
18
+ env = os.environ.copy()
19
+ env["CODEX_HOME"] = codex_home
20
+ os.makedirs(codex_home, mode=0o700, exist_ok=True)
21
+
22
+ subprocess.run([codex_command, "logout"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
23
+
24
+ pid, master_fd = pty.fork()
25
+ if pid == 0:
26
+ os.environ.update(env)
27
+ os.execv(codex_command, [codex_command, "login", "--device-auth"])
28
+
29
+ output = ""
30
+ deadline = time.time() + 15
31
+ code_re = re.compile(r"(?<![A-Z0-9])[A-Z0-9]{4,5}-[A-Z0-9]{4,5}(?![A-Z0-9])")
32
+ url = "https://auth.openai.com/codex/device"
33
+
34
+ try:
35
+ while time.time() < deadline:
36
+ ready, _, _ = select.select([master_fd], [], [], 0.2)
37
+ if not ready:
38
+ try:
39
+ finished_pid, _ = os.waitpid(pid, os.WNOHANG)
40
+ except ChildProcessError:
41
+ finished_pid = pid
42
+ if finished_pid:
43
+ break
44
+ continue
45
+ try:
46
+ chunk = os.read(master_fd, 4096).decode("utf-8", "replace")
47
+ except OSError:
48
+ break
49
+ output += chunk
50
+ match = code_re.search(output)
51
+ if match:
52
+ try:
53
+ os.kill(pid, 15)
54
+ except ProcessLookupError:
55
+ pass
56
+ print(json.dumps({
57
+ "ok": True,
58
+ "account": account_id,
59
+ "url": url,
60
+ "code": match.group(0),
61
+ "expiresInMinutes": 15,
62
+ }))
63
+ return 0
64
+ finally:
65
+ try:
66
+ os.close(master_fd)
67
+ except OSError:
68
+ pass
69
+ try:
70
+ os.kill(pid, 15)
71
+ except ProcessLookupError:
72
+ pass
73
+
74
+ print(json.dumps({"ok": False, "account": account_id, "error": "login code generation timed out"}))
75
+ return 1
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [ "$#" -ne 1 ]; then
5
+ echo "Usage: $0 <account-id>" >&2
6
+ exit 2
7
+ fi
8
+
9
+ account="$1"
10
+
11
+ accounts_dir="${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}"
12
+ export CODEX_HOME="$accounts_dir/$account"
13
+ mkdir -p "$CODEX_HOME"
14
+ chmod 700 "$CODEX_HOME"
15
+
16
+ echo "Logging in $account with CODEX_HOME=$CODEX_HOME"
17
+ echo "Do not paste tokens into chat. Complete the browser/device flow shown by Codex."
18
+ exec codex login --device-auth
package/server.js ADDED
@@ -0,0 +1,433 @@
1
+ const http = require("node:http");
2
+ const crypto = require("node:crypto");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { spawn } = require("node:child_process");
6
+
7
+ const ROOT = __dirname;
8
+ const CWD_CONFIG_PATH = path.join(process.cwd(), "config.json");
9
+ const CONFIG_PATH =
10
+ process.env.CODEX_USAGE_DASHBOARD_CONFIG ||
11
+ (fs.existsSync(CWD_CONFIG_PATH) ? CWD_CONFIG_PATH : path.join(ROOT, "config.json"));
12
+ const DEFAULT_CONFIG = {
13
+ host: "127.0.0.1",
14
+ port: 8787,
15
+ refreshSeconds: 60,
16
+ accounts: [],
17
+ };
18
+
19
+ function loadConfig() {
20
+ if (!fs.existsSync(CONFIG_PATH)) {
21
+ return {
22
+ ...DEFAULT_CONFIG,
23
+ configMissing: true,
24
+ configPath: CONFIG_PATH,
25
+ };
26
+ }
27
+
28
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
29
+ return {
30
+ ...DEFAULT_CONFIG,
31
+ ...parsed,
32
+ accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [],
33
+ configPath: CONFIG_PATH,
34
+ };
35
+ }
36
+
37
+ function json(res, status, body) {
38
+ const data = Buffer.from(JSON.stringify(body, null, 2));
39
+ res.writeHead(status, {
40
+ "content-type": "application/json; charset=utf-8",
41
+ "cache-control": "no-store",
42
+ "content-length": data.length,
43
+ });
44
+ res.end(data);
45
+ }
46
+
47
+ function staticFile(res, filePath, contentType) {
48
+ fs.readFile(filePath, (err, data) => {
49
+ if (err) {
50
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
51
+ res.end("Not found");
52
+ return;
53
+ }
54
+ res.writeHead(200, {
55
+ "content-type": contentType,
56
+ "cache-control": "no-store",
57
+ "content-length": data.length,
58
+ });
59
+ res.end(data);
60
+ });
61
+ }
62
+
63
+ function redactError(message) {
64
+ return String(message || "")
65
+ .replace(/[A-Za-z0-9_-]{32,}/g, "<redacted>")
66
+ .slice(0, 1200);
67
+ }
68
+
69
+ function basicAuthConfig() {
70
+ const username = process.env.CODEX_USAGE_DASHBOARD_BASIC_USER || "";
71
+ const password = process.env.CODEX_USAGE_DASHBOARD_BASIC_PASSWORD || "";
72
+ if (!username || !password) return null;
73
+ return { username, password };
74
+ }
75
+
76
+ function safeEqual(left, right) {
77
+ const leftBuffer = Buffer.from(String(left));
78
+ const rightBuffer = Buffer.from(String(right));
79
+ if (leftBuffer.length !== rightBuffer.length) return false;
80
+ return crypto.timingSafeEqual(leftBuffer, rightBuffer);
81
+ }
82
+
83
+ function unauthorized(res) {
84
+ res.writeHead(401, {
85
+ "www-authenticate": 'Basic realm="Codex Usage Dashboard", charset="UTF-8"',
86
+ "content-type": "text/plain; charset=utf-8",
87
+ "cache-control": "no-store",
88
+ });
89
+ res.end("Authentication required");
90
+ }
91
+
92
+ function isAuthorized(req) {
93
+ const auth = basicAuthConfig();
94
+ if (!auth) return true;
95
+ const header = req.headers.authorization || "";
96
+ const match = header.match(/^Basic\s+(.+)$/i);
97
+ if (!match) return false;
98
+ let decoded = "";
99
+ try {
100
+ decoded = Buffer.from(match[1], "base64").toString("utf8");
101
+ } catch {
102
+ return false;
103
+ }
104
+ const separator = decoded.indexOf(":");
105
+ if (separator < 0) return false;
106
+ const username = decoded.slice(0, separator);
107
+ const password = decoded.slice(separator + 1);
108
+ return safeEqual(username, auth.username) && safeEqual(password, auth.password);
109
+ }
110
+
111
+ function normalizeWindow(window) {
112
+ if (!window) return null;
113
+ const usedPercent = typeof window.usedPercent === "number" ? window.usedPercent : null;
114
+ return {
115
+ usedPercent,
116
+ remainingPercent: typeof usedPercent === "number" ? Math.max(0, Math.round((100 - usedPercent) * 100) / 100) : null,
117
+ resetsAt: window.resetsAt || null,
118
+ windowDurationMins: window.windowDurationMins || null,
119
+ };
120
+ }
121
+
122
+ function normalizeLimit(limit) {
123
+ if (!limit) return null;
124
+ return {
125
+ limitId: limit.limitId || null,
126
+ limitName: limit.limitName || null,
127
+ planType: limit.planType || null,
128
+ primary: normalizeWindow(limit.primary),
129
+ secondary: normalizeWindow(limit.secondary),
130
+ credits: limit.credits
131
+ ? {
132
+ hasCredits: Boolean(limit.credits.hasCredits),
133
+ unlimited: Boolean(limit.credits.unlimited),
134
+ balance: limit.credits.balance ?? null,
135
+ }
136
+ : null,
137
+ individualLimit: limit.individualLimit || null,
138
+ rateLimitReachedType: limit.rateLimitReachedType || null,
139
+ };
140
+ }
141
+
142
+ function normalizeAccount(accountResponse) {
143
+ const account = accountResponse?.account || null;
144
+ return {
145
+ type: account?.type || null,
146
+ planType: account?.planType || null,
147
+ hasEmail: Boolean(account?.email),
148
+ requiresOpenaiAuth: Boolean(accountResponse?.requiresOpenaiAuth),
149
+ };
150
+ }
151
+
152
+ function findAccount(id) {
153
+ const config = loadConfig();
154
+ if (config.configMissing) {
155
+ return null;
156
+ }
157
+ return config.accounts.find((account, index) => (account.id || "account-" + (index + 1)) === id) || null;
158
+ }
159
+
160
+ function configuredAccounts() {
161
+ const config = loadConfig();
162
+ if (config.configMissing) return [];
163
+ return config.accounts.map((account, index) => ({
164
+ id: account.id || "account-" + (index + 1),
165
+ label: account.label || account.id || "Account " + (index + 1),
166
+ }));
167
+ }
168
+
169
+ function createCodexEnv(account) {
170
+ const env = { ...process.env };
171
+ if (account.codexHome) {
172
+ env.CODEX_HOME = account.codexHome;
173
+ }
174
+ return env;
175
+ }
176
+
177
+ function requestCodex(account) {
178
+ const codexCommand = account.codexCommand || "codex";
179
+ const env = createCodexEnv(account);
180
+
181
+ const child = spawn(codexCommand, ["app-server", "--stdio"], {
182
+ env,
183
+ stdio: ["pipe", "pipe", "pipe"],
184
+ windowsHide: true,
185
+ });
186
+
187
+ let buffer = "";
188
+ let stderr = "";
189
+ let nextId = 1;
190
+ const pending = new Map();
191
+
192
+ const cleanup = () => {
193
+ for (const entry of pending.values()) {
194
+ clearTimeout(entry.timer);
195
+ }
196
+ pending.clear();
197
+ if (!child.killed) child.kill();
198
+ };
199
+
200
+ function send(method, params = {}, timeoutMs = 15000) {
201
+ const id = nextId++;
202
+ child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
203
+ return new Promise((resolve, reject) => {
204
+ const timer = setTimeout(() => {
205
+ pending.delete(id);
206
+ reject(new Error(method + " timed out"));
207
+ }, timeoutMs);
208
+ pending.set(id, { resolve, reject, timer });
209
+ });
210
+ }
211
+
212
+ child.stdout.on("data", (chunk) => {
213
+ buffer += chunk.toString("utf8");
214
+ let newline;
215
+ while ((newline = buffer.indexOf("\n")) >= 0) {
216
+ const line = buffer.slice(0, newline).trim();
217
+ buffer = buffer.slice(newline + 1);
218
+ if (!line) continue;
219
+ let message;
220
+ try {
221
+ message = JSON.parse(line);
222
+ } catch {
223
+ continue;
224
+ }
225
+ if (typeof message.id !== "number") continue;
226
+ const item = pending.get(message.id);
227
+ if (!item) continue;
228
+ pending.delete(message.id);
229
+ clearTimeout(item.timer);
230
+ if (message.error) {
231
+ item.reject(new Error(message.error.message || JSON.stringify(message.error)));
232
+ } else {
233
+ item.resolve(message.result);
234
+ }
235
+ }
236
+ });
237
+
238
+ child.stderr.on("data", (chunk) => {
239
+ stderr += chunk.toString("utf8");
240
+ });
241
+
242
+ return new Promise((resolve) => {
243
+ child.once("error", (error) => {
244
+ cleanup();
245
+ resolve({ ok: false, error: redactError(error.message) });
246
+ });
247
+
248
+ (async () => {
249
+ try {
250
+ await send("initialize", {
251
+ clientInfo: {
252
+ name: "violet-codex-usage-dashboard",
253
+ title: "Violet Codex Usage Dashboard",
254
+ version: "0.1.0",
255
+ },
256
+ capabilities: {
257
+ experimentalApi: true,
258
+ },
259
+ });
260
+ const [accountInfo, limits] = await Promise.all([
261
+ send("account/read", { refreshToken: false }),
262
+ send("account/rateLimits/read", {}),
263
+ ]);
264
+ cleanup();
265
+ resolve({
266
+ ok: true,
267
+ id: account.id,
268
+ label: account.label || account.id,
269
+ account: normalizeAccount(accountInfo),
270
+ rateLimits: normalizeLimit(limits?.rateLimits),
271
+ rateLimitsByLimitId: Object.fromEntries(
272
+ Object.entries(limits?.rateLimitsByLimitId || {}).map(([key, value]) => [key, normalizeLimit(value)]),
273
+ ),
274
+ fetchedAt: new Date().toISOString(),
275
+ });
276
+ } catch (error) {
277
+ cleanup();
278
+ resolve({
279
+ ok: false,
280
+ id: account.id,
281
+ label: account.label || account.id,
282
+ error: redactError(error.message),
283
+ stderr: redactError(stderr),
284
+ fetchedAt: new Date().toISOString(),
285
+ });
286
+ }
287
+ })();
288
+ });
289
+ }
290
+
291
+ function createLoginCode(account) {
292
+ const codexCommand = account.codexCommand || "codex";
293
+ const codexHome = account.codexHome || path.join(process.env.HOME || process.cwd(), ".codex");
294
+ const child = spawn("python3", [path.join(ROOT, "scripts", "codex-device-code.py"), account.id, codexCommand, codexHome], {
295
+ env: process.env,
296
+ stdio: ["ignore", "pipe", "pipe"],
297
+ windowsHide: true,
298
+ });
299
+
300
+ let output = "";
301
+ let settled = false;
302
+
303
+ function parseLoginOutput(text) {
304
+ try {
305
+ const parsed = JSON.parse(text.trim().split("\n").at(-1));
306
+ return {
307
+ ...parsed,
308
+ label: account.label || account.id,
309
+ generatedAt: new Date().toISOString(),
310
+ };
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+
316
+ return new Promise((resolve) => {
317
+ const timer = setTimeout(() => {
318
+ if (settled) return;
319
+ settled = true;
320
+ if (!child.killed) child.kill();
321
+ resolve({ ok: false, account: account.id, error: "login code generation timed out" });
322
+ }, 10000);
323
+
324
+ function onData(chunk) {
325
+ output += chunk.toString("utf8");
326
+ const parsed = parseLoginOutput(output);
327
+ if (!parsed || settled) return;
328
+ settled = true;
329
+ clearTimeout(timer);
330
+ if (!child.killed) child.kill();
331
+ resolve(parsed);
332
+ }
333
+
334
+ child.stdout.on("data", onData);
335
+ child.stderr.on("data", onData);
336
+ child.once("error", (error) => {
337
+ if (settled) return;
338
+ settled = true;
339
+ clearTimeout(timer);
340
+ resolve({ ok: false, account: account.id, error: redactError(error.message) });
341
+ });
342
+ child.once("exit", () => {
343
+ if (settled) return;
344
+ settled = true;
345
+ clearTimeout(timer);
346
+ resolve({ ok: false, account: account.id, error: "codex login exited before producing a code" });
347
+ });
348
+ });
349
+ }
350
+
351
+ async function usage() {
352
+ const config = loadConfig();
353
+ if (config.configMissing) {
354
+ return {
355
+ ok: false,
356
+ error: "config.json is missing. Copy config.example.json to config.json and configure accounts.",
357
+ configPath: config.configPath,
358
+ accounts: [],
359
+ };
360
+ }
361
+
362
+ const accounts = await Promise.all(
363
+ config.accounts.map((account, index) =>
364
+ requestCodex({
365
+ ...account,
366
+ id: account.id || "account-" + (index + 1),
367
+ }),
368
+ ),
369
+ );
370
+
371
+ return {
372
+ ok: accounts.every((account) => account.ok),
373
+ refreshSeconds: config.refreshSeconds,
374
+ fetchedAt: new Date().toISOString(),
375
+ accounts,
376
+ };
377
+ }
378
+
379
+ const server = http.createServer(async (req, res) => {
380
+ if (!isAuthorized(req)) {
381
+ unauthorized(res);
382
+ return;
383
+ }
384
+
385
+ const url = new URL(req.url, "http://localhost");
386
+ if (url.pathname === "/" || url.pathname === "/index.html") {
387
+ staticFile(res, path.join(ROOT, "public", "index.html"), "text/html; charset=utf-8");
388
+ return;
389
+ }
390
+ if (url.pathname === "/app.js") {
391
+ staticFile(res, path.join(ROOT, "public", "app.js"), "application/javascript; charset=utf-8");
392
+ return;
393
+ }
394
+ if (url.pathname === "/style.css") {
395
+ staticFile(res, path.join(ROOT, "public", "style.css"), "text/css; charset=utf-8");
396
+ return;
397
+ }
398
+ if (url.pathname === "/api/usage") {
399
+ try {
400
+ json(res, 200, await usage());
401
+ } catch (error) {
402
+ json(res, 500, { ok: false, error: redactError(error.message) });
403
+ }
404
+ return;
405
+ }
406
+ if (url.pathname === "/api/accounts") {
407
+ json(res, 200, { ok: true, accounts: configuredAccounts() });
408
+ return;
409
+ }
410
+ if (url.pathname === "/api/login-code") {
411
+ const accountId = url.searchParams.get("account") || "";
412
+ const account = findAccount(accountId);
413
+ if (!account) {
414
+ json(res, 404, { ok: false, error: "Unknown account" });
415
+ return;
416
+ }
417
+ try {
418
+ json(res, 200, await createLoginCode({ ...account, id: accountId }));
419
+ } catch (error) {
420
+ json(res, 500, { ok: false, error: redactError(error.message) });
421
+ }
422
+ return;
423
+ }
424
+ json(res, 404, { ok: false, error: "Not found" });
425
+ });
426
+
427
+ const config = loadConfig();
428
+ server.listen(config.port, config.host, () => {
429
+ console.log("Codex usage dashboard listening on http://" + config.host + ":" + config.port);
430
+ if (config.configMissing) {
431
+ console.log("Missing config: copy " + path.join(ROOT, "config.example.json") + " to " + CONFIG_PATH);
432
+ }
433
+ });