@kodo/agent-meter 1.0.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,37 @@
1
+ # agent-meter
2
+
3
+ CLI tool for managing multiple Codex OAuth accounts and checking their rate limits / usage.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm install
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Add a new account (opens browser for OAuth login, then auto-checks usage)
15
+ pnpm dev add
16
+
17
+ # List all accounts with real-time usage check
18
+ pnpm dev list
19
+
20
+ # Remove an account by email
21
+ pnpm dev delete <email>
22
+ ```
23
+
24
+ ## How it works
25
+
26
+ - `add` creates an isolated `CODEX_HOME` directory, runs `codex login`, then immediately checks usage
27
+ - `list` concurrently checks all accounts via the OAuth usage API and displays a table with progress bars
28
+ - `delete` removes the account and its `CODEX_HOME` directory
29
+ - If a token expires during `list`, you'll be prompted to re-login on the spot
30
+
31
+ ## Options
32
+
33
+ | Flag | Description |
34
+ |------|-------------|
35
+ | `--json` | Output raw JSON |
36
+ | `--verbose` | Enable verbose logging |
37
+ | `--data-dir <path>` | Override the default `.data` directory |
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import readline from 'node:readline';
4
+ import { Command } from 'commander';
5
+ import { createCodexHome, createDataPaths, ensureDataPaths, makeId, markLastUsed, nowIso, normalizeAccount, readStore, removeAccountFromStore, resolveAccountRef, updateAccount, writeStore, } from './core/accounts.js';
6
+ import { ensureCodexInstalled, loadCredentials, runCodexLogin } from './core/auth.js';
7
+ import { printJson, renderAccountsTable } from './core/output.js';
8
+ import { checkAccount } from './core/usage.js';
9
+ function resolveDataDir(opts) {
10
+ if (!opts.dataDir)
11
+ return `${process.cwd()}/.data`;
12
+ const resolved = opts.dataDir.startsWith('/') ? opts.dataDir : `${process.cwd()}/${opts.dataDir}`;
13
+ return fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
14
+ }
15
+ function getStore(opts) {
16
+ const dataDir = resolveDataDir(opts);
17
+ const paths = createDataPaths(dataDir);
18
+ ensureDataPaths(paths);
19
+ return {
20
+ paths,
21
+ store: readStore(paths),
22
+ };
23
+ }
24
+ function confirm(question) {
25
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
26
+ return new Promise(resolve => {
27
+ rl.question(question, answer => {
28
+ rl.close();
29
+ resolve(answer.trim().toLowerCase() === 'y');
30
+ });
31
+ });
32
+ }
33
+ const program = new Command();
34
+ program
35
+ .name('agent-meter')
36
+ .description('Multi-account Codex OAuth usage checker')
37
+ .option('--json', 'output JSON')
38
+ .option('--verbose', 'enable verbose logging')
39
+ .option('--data-dir <path>', 'override .data directory');
40
+ program
41
+ .command('add')
42
+ .description('add a new account via codex login')
43
+ .action(async () => {
44
+ const opts = program.opts();
45
+ const { paths, store } = getStore(opts);
46
+ ensureCodexInstalled();
47
+ const accountId = makeId();
48
+ const codexHome = createCodexHome(paths, accountId);
49
+ runCodexLogin(codexHome, Boolean(opts.verbose));
50
+ const credentials = loadCredentials(codexHome);
51
+ const label = credentials.email || credentials.accountId || accountId;
52
+ const account = markLastUsed(normalizeAccount({
53
+ id: accountId,
54
+ label,
55
+ email: credentials.email,
56
+ accountId: credentials.accountId,
57
+ codexHome,
58
+ createdAt: nowIso(),
59
+ lastUsedAt: nowIso(),
60
+ }));
61
+ let nextStore = updateAccount(store, account, { makeDefault: store.accounts.length === 0 });
62
+ writeStore(paths, nextStore);
63
+ process.stdout.write(`\n✓ Added account '${account.email || account.label}'.\n`);
64
+ process.stdout.write(`\nChecking usage...\n`);
65
+ const checked = await checkAccount(account);
66
+ const checkedAccount = markLastUsed(normalizeAccount({
67
+ ...checked.account,
68
+ lastCheck: checked.result,
69
+ }), nowIso());
70
+ nextStore = updateAccount(nextStore, checkedAccount);
71
+ writeStore(paths, nextStore);
72
+ if (opts.json) {
73
+ printJson({ accounts: nextStore.accounts });
74
+ return;
75
+ }
76
+ process.stdout.write(`\n${renderAccountsTable(nextStore)}\n`);
77
+ });
78
+ program
79
+ .command('list')
80
+ .description('check and list all accounts')
81
+ .action(async () => {
82
+ const opts = program.opts();
83
+ const { paths, store } = getStore(opts);
84
+ if (store.accounts.length === 0) {
85
+ if (opts.json) {
86
+ printJson({ accounts: [] });
87
+ }
88
+ else {
89
+ process.stdout.write(`${renderAccountsTable(store)}\n`);
90
+ }
91
+ return;
92
+ }
93
+ process.stdout.write('Checking all accounts...\n');
94
+ const results = await Promise.all(store.accounts.map(account => checkAccount(account)));
95
+ let nextStore = store;
96
+ for (const checked of results) {
97
+ const nextAccount = markLastUsed(normalizeAccount({
98
+ ...checked.account,
99
+ lastCheck: checked.result,
100
+ }), nowIso());
101
+ nextStore = updateAccount(nextStore, nextAccount);
102
+ }
103
+ writeStore(paths, nextStore);
104
+ const expired = nextStore.accounts.filter(a => a.lastCheck && !a.lastCheck.ok && a.lastCheck.error?.includes('Unauthorized'));
105
+ if (expired.length > 0 && !opts.json) {
106
+ process.stdout.write(`\n${renderAccountsTable(nextStore)}\n\n`);
107
+ for (const account of expired) {
108
+ const yes = await confirm(`⚠ Account '${account.email || account.label}' token expired. Re-login? (y/N) `);
109
+ if (!yes)
110
+ continue;
111
+ ensureCodexInstalled();
112
+ runCodexLogin(account.codexHome, Boolean(opts.verbose));
113
+ const credentials = loadCredentials(account.codexHome);
114
+ const refreshed = markLastUsed(normalizeAccount({
115
+ ...account,
116
+ email: credentials.email || account.email,
117
+ accountId: credentials.accountId || account.accountId,
118
+ }), nowIso());
119
+ const rechecked = await checkAccount(refreshed);
120
+ const recheckedAccount = markLastUsed(normalizeAccount({
121
+ ...rechecked.account,
122
+ lastCheck: rechecked.result,
123
+ }), nowIso());
124
+ nextStore = updateAccount(nextStore, recheckedAccount);
125
+ writeStore(paths, nextStore);
126
+ process.stdout.write(`✓ Re-logged '${recheckedAccount.email || recheckedAccount.label}'.\n`);
127
+ }
128
+ process.stdout.write(`\n${renderAccountsTable(nextStore)}\n`);
129
+ return;
130
+ }
131
+ if (opts.json) {
132
+ printJson({ accounts: nextStore.accounts });
133
+ return;
134
+ }
135
+ process.stdout.write(`${renderAccountsTable(nextStore)}\n`);
136
+ });
137
+ program
138
+ .command('delete')
139
+ .argument('<email>')
140
+ .description('remove an account by email')
141
+ .action((email) => {
142
+ const opts = program.opts();
143
+ const { paths, store } = getStore(opts);
144
+ const account = resolveAccountRef(store, email);
145
+ if (account.email !== email.trim()) {
146
+ throw new Error(`Account '${email}' not found by email.`);
147
+ }
148
+ const nextStore = removeAccountFromStore(store, account.id);
149
+ writeStore(paths, nextStore);
150
+ if (fs.existsSync(account.codexHome)) {
151
+ fs.rmSync(account.codexHome, { recursive: true, force: true });
152
+ }
153
+ if (opts.json) {
154
+ printJson({ removedAccountId: account.id, accounts: nextStore.accounts });
155
+ return;
156
+ }
157
+ process.stdout.write(`✓ Deleted '${account.email || account.label}'.\n`);
158
+ });
159
+ program.parseAsync(process.argv).catch((error) => {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ process.stderr.write(`${message}\n`);
162
+ process.exit(1);
163
+ });
@@ -0,0 +1,197 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const STORE_VERSION = 1;
4
+ export function createDataPaths(dataDir) {
5
+ return {
6
+ dataDir,
7
+ accountsFile: path.join(dataDir, 'accounts.json'),
8
+ codexHomesDir: path.join(dataDir, 'codex-homes'),
9
+ };
10
+ }
11
+ export function ensureDataPaths(paths) {
12
+ fs.mkdirSync(paths.dataDir, { recursive: true });
13
+ fs.mkdirSync(paths.codexHomesDir, { recursive: true });
14
+ }
15
+ export function nowIso() {
16
+ return new Date().toISOString();
17
+ }
18
+ export function makeId() {
19
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
20
+ }
21
+ function numberOrNull(value) {
22
+ const parsed = Number(value);
23
+ return Number.isFinite(parsed) ? parsed : null;
24
+ }
25
+ export function normalizeLastCheck(lastCheck) {
26
+ if (!lastCheck || typeof lastCheck !== 'object')
27
+ return null;
28
+ const input = lastCheck;
29
+ return {
30
+ ok: Boolean(input.ok),
31
+ planType: typeof input.planType === 'string' ? input.planType : null,
32
+ primaryUsedPercent: numberOrNull(input.primaryUsedPercent),
33
+ primaryRemainingPercent: numberOrNull(input.primaryRemainingPercent),
34
+ primaryResetAt: typeof input.primaryResetAt === 'string' ? input.primaryResetAt : null,
35
+ secondaryUsedPercent: numberOrNull(input.secondaryUsedPercent),
36
+ secondaryRemainingPercent: numberOrNull(input.secondaryRemainingPercent),
37
+ secondaryResetAt: typeof input.secondaryResetAt === 'string' ? input.secondaryResetAt : null,
38
+ creditsBalance: numberOrNull(input.creditsBalance),
39
+ creditsUnlimited: input.creditsUnlimited == null ? null : Boolean(input.creditsUnlimited),
40
+ sourceUsed: 'oauth',
41
+ checkedAt: typeof input.checkedAt === 'string' ? input.checkedAt : nowIso(),
42
+ elapsedMs: numberOrNull(input.elapsedMs) ?? 0,
43
+ error: typeof input.error === 'string' ? input.error : null,
44
+ };
45
+ }
46
+ export function normalizeAccount(account) {
47
+ return {
48
+ id: String(account.id),
49
+ label: account.label?.trim() || 'Codex Account',
50
+ email: account.email ?? null,
51
+ accountId: account.accountId ?? null,
52
+ codexHome: account.codexHome,
53
+ createdAt: account.createdAt || nowIso(),
54
+ lastUsedAt: account.lastUsedAt ?? null,
55
+ lastCheck: normalizeLastCheck(account.lastCheck),
56
+ };
57
+ }
58
+ function migrateStore(raw) {
59
+ if (Array.isArray(raw)) {
60
+ const accounts = raw
61
+ .filter((item) => Boolean(item) && typeof item === 'object')
62
+ .filter(item => typeof item.id === 'string' && typeof item.codexHome === 'string')
63
+ .map(item => normalizeAccount({
64
+ id: item.id,
65
+ label: typeof item.label === 'string' ? item.label : 'Codex Account',
66
+ email: typeof item.email === 'string' ? item.email : null,
67
+ accountId: typeof item.accountId === 'string' ? item.accountId : null,
68
+ codexHome: item.codexHome,
69
+ createdAt: typeof item.createdAt === 'string' ? item.createdAt : nowIso(),
70
+ lastUsedAt: typeof item.lastUsedAt === 'string' ? item.lastUsedAt : null,
71
+ lastCheck: item.lastCheck,
72
+ }));
73
+ return {
74
+ version: STORE_VERSION,
75
+ defaultAccountId: accounts[0]?.id ?? null,
76
+ accounts,
77
+ };
78
+ }
79
+ const input = raw && typeof raw === 'object' ? raw : {};
80
+ const accounts = Array.isArray(input.accounts)
81
+ ? input.accounts
82
+ .filter((item) => Boolean(item) && typeof item === 'object')
83
+ .filter(item => typeof item.id === 'string' && typeof item.codexHome === 'string')
84
+ .map(item => normalizeAccount({
85
+ id: item.id,
86
+ label: typeof item.label === 'string' ? item.label : 'Codex Account',
87
+ email: typeof item.email === 'string' ? item.email : null,
88
+ accountId: typeof item.accountId === 'string' ? item.accountId : null,
89
+ codexHome: item.codexHome,
90
+ createdAt: typeof item.createdAt === 'string' ? item.createdAt : nowIso(),
91
+ lastUsedAt: typeof item.lastUsedAt === 'string' ? item.lastUsedAt : null,
92
+ lastCheck: item.lastCheck,
93
+ }))
94
+ : [];
95
+ const defaultAccountId = typeof input.defaultAccountId === 'string' ? input.defaultAccountId : null;
96
+ return {
97
+ version: STORE_VERSION,
98
+ defaultAccountId: accounts.some(account => account.id === defaultAccountId)
99
+ ? defaultAccountId
100
+ : accounts[0]?.id ?? null,
101
+ accounts,
102
+ };
103
+ }
104
+ export function readStore(paths) {
105
+ ensureDataPaths(paths);
106
+ try {
107
+ const raw = JSON.parse(fs.readFileSync(paths.accountsFile, 'utf8'));
108
+ return migrateStore(raw);
109
+ }
110
+ catch {
111
+ return {
112
+ version: STORE_VERSION,
113
+ defaultAccountId: null,
114
+ accounts: [],
115
+ };
116
+ }
117
+ }
118
+ export function writeStore(paths, store) {
119
+ ensureDataPaths(paths);
120
+ const normalized = {
121
+ version: STORE_VERSION,
122
+ defaultAccountId: store.defaultAccountId,
123
+ accounts: store.accounts.map(account => normalizeAccount(account)),
124
+ };
125
+ fs.writeFileSync(paths.accountsFile, JSON.stringify(normalized, null, 2));
126
+ }
127
+ export function createCodexHome(paths, accountId) {
128
+ const codexHome = path.join(paths.codexHomesDir, accountId);
129
+ fs.mkdirSync(codexHome, { recursive: true });
130
+ const configPath = path.join(codexHome, 'config.toml');
131
+ if (!fs.existsSync(configPath)) {
132
+ fs.writeFileSync(configPath, '');
133
+ }
134
+ return codexHome;
135
+ }
136
+ export function updateAccount(store, nextAccount, options = {}) {
137
+ const existingIndex = store.accounts.findIndex(account => account.id === nextAccount.id);
138
+ const accounts = [...store.accounts];
139
+ if (existingIndex >= 0) {
140
+ accounts[existingIndex] = normalizeAccount(nextAccount);
141
+ }
142
+ else {
143
+ accounts.push(normalizeAccount(nextAccount));
144
+ }
145
+ const defaultAccountId = options.makeDefault
146
+ ? nextAccount.id
147
+ : (store.defaultAccountId ?? (accounts[0]?.id ?? null));
148
+ return {
149
+ version: STORE_VERSION,
150
+ defaultAccountId,
151
+ accounts,
152
+ };
153
+ }
154
+ export function markLastUsed(account, at = nowIso()) {
155
+ return {
156
+ ...account,
157
+ lastUsedAt: at,
158
+ };
159
+ }
160
+ export function resolveAccountRef(store, ref) {
161
+ const trimmed = ref.trim();
162
+ const byId = store.accounts.find(account => account.id === trimmed);
163
+ if (byId)
164
+ return byId;
165
+ const byEmail = store.accounts.filter(account => account.email === trimmed);
166
+ if (byEmail.length === 1)
167
+ return byEmail[0];
168
+ if (byEmail.length > 1) {
169
+ throw new Error(`Email '${trimmed}' matches multiple accounts. Use account id instead.`);
170
+ }
171
+ const matches = store.accounts.filter(account => account.label === trimmed);
172
+ if (matches.length === 1)
173
+ return matches[0];
174
+ if (matches.length > 1) {
175
+ throw new Error(`Label '${trimmed}' matches multiple accounts. Use account id instead.`);
176
+ }
177
+ throw new Error(`Account '${trimmed}' not found.`);
178
+ }
179
+ export function removeAccountFromStore(store, accountId) {
180
+ const accountExists = store.accounts.some(account => account.id === accountId);
181
+ if (!accountExists) {
182
+ throw new Error('Account not found');
183
+ }
184
+ const accounts = store.accounts.filter(account => account.id !== accountId);
185
+ let defaultAccountId = store.defaultAccountId;
186
+ if (defaultAccountId === accountId) {
187
+ const nextDefault = [...accounts].sort((a, b) => {
188
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
189
+ })[0];
190
+ defaultAccountId = nextDefault?.id ?? null;
191
+ }
192
+ return {
193
+ version: STORE_VERSION,
194
+ defaultAccountId,
195
+ accounts,
196
+ };
197
+ }
@@ -0,0 +1,165 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ export const REFRESH_URL = 'https://auth.openai.com/oauth/token';
5
+ export const OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
6
+ export const TOKEN_REFRESH_INTERVAL_MS = 8 * 24 * 60 * 60 * 1000;
7
+ export function authFilePath(codexHome) {
8
+ return path.join(codexHome, 'auth.json');
9
+ }
10
+ function parseLastRefresh(raw) {
11
+ if (typeof raw !== 'string')
12
+ return null;
13
+ const parsed = new Date(raw);
14
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
15
+ }
16
+ function decodeJwtPayload(token) {
17
+ if (!token)
18
+ return null;
19
+ const parts = token.split('.');
20
+ if (parts.length < 2)
21
+ return null;
22
+ const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
23
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
24
+ try {
25
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function resolveEmailFromTokens(tokens) {
32
+ const payload = decodeJwtPayload(asString(tokens.id_token)) || decodeJwtPayload(asString(tokens.access_token));
33
+ const profile = payload?.['https://api.openai.com/profile'];
34
+ const email = asString(payload?.email) || asString(profile?.email);
35
+ return email || null;
36
+ }
37
+ function resolvePlanFromTokens(tokens) {
38
+ const payload = decodeJwtPayload(asString(tokens.id_token)) || decodeJwtPayload(asString(tokens.access_token));
39
+ const auth = payload?.['https://api.openai.com/auth'];
40
+ return asString(auth?.chatgpt_plan_type) || asString(payload?.chatgpt_plan_type) || null;
41
+ }
42
+ function asString(value) {
43
+ return typeof value === 'string' && value.trim() ? value : null;
44
+ }
45
+ export function ensureCodexInstalled() {
46
+ const result = spawnSync('codex', ['--version'], {
47
+ stdio: 'ignore',
48
+ env: process.env,
49
+ });
50
+ if (result.error || result.status !== 0) {
51
+ throw new Error('codex CLI not found. Install it first and ensure it is on PATH.');
52
+ }
53
+ }
54
+ export function runCodexLogin(codexHome, verbose) {
55
+ ensureCodexInstalled();
56
+ const result = spawnSync('codex', ['login'], {
57
+ stdio: 'inherit',
58
+ env: {
59
+ ...process.env,
60
+ CODEX_HOME: codexHome,
61
+ },
62
+ });
63
+ if (result.error) {
64
+ throw result.error;
65
+ }
66
+ if (result.status !== 0) {
67
+ throw new Error(`codex login failed with exit code ${result.status ?? 1}`);
68
+ }
69
+ if (verbose) {
70
+ process.stderr.write(`login completed for CODEX_HOME=${codexHome}\n`);
71
+ }
72
+ }
73
+ export function loadCredentials(codexHome) {
74
+ const file = authFilePath(codexHome);
75
+ if (!fs.existsSync(file)) {
76
+ throw new Error('auth.json not found. Run login first.');
77
+ }
78
+ const json = JSON.parse(fs.readFileSync(file, 'utf8'));
79
+ const apiKey = asString(json.OPENAI_API_KEY);
80
+ if (apiKey) {
81
+ return {
82
+ authMode: 'api_key',
83
+ accessToken: apiKey,
84
+ refreshToken: '',
85
+ idToken: null,
86
+ accountId: null,
87
+ email: null,
88
+ planType: null,
89
+ lastRefresh: null,
90
+ };
91
+ }
92
+ const tokens = (json.tokens && typeof json.tokens === 'object') ? json.tokens : {};
93
+ const accessToken = asString(tokens.access_token);
94
+ if (!accessToken) {
95
+ throw new Error('auth.json exists but tokens are missing');
96
+ }
97
+ return {
98
+ authMode: asString(json.auth_mode) || 'chatgpt',
99
+ accessToken,
100
+ refreshToken: asString(tokens.refresh_token) || '',
101
+ idToken: asString(tokens.id_token),
102
+ accountId: asString(tokens.account_id),
103
+ email: resolveEmailFromTokens(tokens),
104
+ planType: resolvePlanFromTokens(tokens),
105
+ lastRefresh: parseLastRefresh(json.last_refresh),
106
+ };
107
+ }
108
+ export function saveCredentials(codexHome, credentials) {
109
+ const file = authFilePath(codexHome);
110
+ let json = {};
111
+ if (fs.existsSync(file)) {
112
+ try {
113
+ json = JSON.parse(fs.readFileSync(file, 'utf8'));
114
+ }
115
+ catch {
116
+ json = {};
117
+ }
118
+ }
119
+ json.auth_mode = credentials.authMode;
120
+ json.tokens = {
121
+ ...(json.tokens && typeof json.tokens === 'object' ? json.tokens : {}),
122
+ access_token: credentials.accessToken,
123
+ refresh_token: credentials.refreshToken,
124
+ id_token: credentials.idToken,
125
+ account_id: credentials.accountId,
126
+ };
127
+ json.last_refresh = new Date().toISOString();
128
+ fs.writeFileSync(file, JSON.stringify(json, null, 2));
129
+ }
130
+ export async function refreshCredentialsIfNeeded(codexHome, credentials) {
131
+ if (!credentials.refreshToken)
132
+ return credentials;
133
+ if (credentials.lastRefresh && Date.now() - credentials.lastRefresh.getTime() < TOKEN_REFRESH_INTERVAL_MS) {
134
+ return credentials;
135
+ }
136
+ const refreshed = await refreshCredentials(credentials);
137
+ saveCredentials(codexHome, refreshed);
138
+ return refreshed;
139
+ }
140
+ export async function refreshCredentials(credentials) {
141
+ const response = await fetch(REFRESH_URL, {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json' },
144
+ body: JSON.stringify({
145
+ client_id: OAUTH_CLIENT_ID,
146
+ grant_type: 'refresh_token',
147
+ refresh_token: credentials.refreshToken,
148
+ scope: 'openid profile email',
149
+ }),
150
+ });
151
+ if (response.status === 401) {
152
+ throw new Error('Refresh token expired or revoked. Run relogin.');
153
+ }
154
+ if (!response.ok) {
155
+ throw new Error(`Token refresh failed with HTTP ${response.status}`);
156
+ }
157
+ const json = await response.json();
158
+ return {
159
+ ...credentials,
160
+ accessToken: asString(json.access_token) || credentials.accessToken,
161
+ refreshToken: asString(json.refresh_token) || credentials.refreshToken,
162
+ idToken: asString(json.id_token) || credentials.idToken,
163
+ lastRefresh: new Date(),
164
+ };
165
+ }
@@ -0,0 +1,182 @@
1
+ const RESET = '\x1b[0m';
2
+ const BOLD = '\x1b[1m';
3
+ const DIM = '\x1b[2m';
4
+ const GREEN = '\x1b[32m';
5
+ const YELLOW = '\x1b[33m';
6
+ const RED = '\x1b[31m';
7
+ const CYAN = '\x1b[36m';
8
+ const WHITE = '\x1b[37m';
9
+ const BG_GREEN = '\x1b[42m';
10
+ const BG_YELLOW = '\x1b[43m';
11
+ const BG_RED = '\x1b[41m';
12
+ const BG_GRAY = '\x1b[100m';
13
+ function colorEnabled() {
14
+ return process.stdout.isTTY !== false && !process.env.NO_COLOR;
15
+ }
16
+ function c(color, text) {
17
+ return colorEnabled() ? `${color}${text}${RESET}` : text;
18
+ }
19
+ function formatDate(value) {
20
+ if (!value)
21
+ return '-';
22
+ const date = new Date(value);
23
+ if (Number.isNaN(date.getTime()))
24
+ return value;
25
+ const now = Date.now();
26
+ const diffMs = now - date.getTime();
27
+ const diffMin = Math.floor(diffMs / 60_000);
28
+ if (diffMin < 1)
29
+ return 'just now';
30
+ if (diffMin < 60)
31
+ return `${diffMin}m ago`;
32
+ const diffHr = Math.floor(diffMin / 60);
33
+ if (diffHr < 24)
34
+ return `${diffHr}h ago`;
35
+ const diffDay = Math.floor(diffHr / 24);
36
+ return `${diffDay}d ago`;
37
+ }
38
+ function progressBar(remainingPercent, width = 12) {
39
+ const clamped = Math.max(0, Math.min(100, remainingPercent));
40
+ const filled = Math.round((clamped / 100) * width);
41
+ const empty = width - filled;
42
+ let barColor;
43
+ if (clamped <= 20)
44
+ barColor = BG_RED;
45
+ else if (clamped <= 50)
46
+ barColor = BG_YELLOW;
47
+ else
48
+ barColor = BG_GREEN;
49
+ if (!colorEnabled()) {
50
+ return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
51
+ }
52
+ return `${barColor}${' '.repeat(filled)}${RESET}${BG_GRAY}${' '.repeat(empty)}${RESET}`;
53
+ }
54
+ function formatUsage(usedPercent, remainingPercent, resetAt) {
55
+ if (usedPercent == null && remainingPercent == null)
56
+ return c(DIM, '-');
57
+ const remaining = remainingPercent ?? (100 - (usedPercent ?? 0));
58
+ const bar = progressBar(remaining);
59
+ const pctText = `${remaining}%`;
60
+ let coloredPct;
61
+ if (remaining <= 20)
62
+ coloredPct = c(RED + BOLD, pctText);
63
+ else if (remaining <= 50)
64
+ coloredPct = c(YELLOW, pctText);
65
+ else
66
+ coloredPct = c(GREEN, pctText);
67
+ let resetText = '';
68
+ if (resetAt) {
69
+ const resetDate = new Date(resetAt);
70
+ if (!Number.isNaN(resetDate.getTime())) {
71
+ const diffMs = resetDate.getTime() - Date.now();
72
+ if (diffMs > 0) {
73
+ const totalMin = Math.floor(diffMs / 60_000);
74
+ const days = Math.floor(totalMin / 1440);
75
+ const hours = Math.floor((totalMin % 1440) / 60);
76
+ const mins = totalMin % 60;
77
+ const parts = [];
78
+ if (days > 0)
79
+ parts.push(`${days}d`);
80
+ if (hours > 0)
81
+ parts.push(`${hours}h`);
82
+ parts.push(`${mins}m`);
83
+ resetText = c(DIM, ` ↻${parts.join('')}`);
84
+ }
85
+ }
86
+ }
87
+ return `${bar} ${coloredPct}${resetText}`;
88
+ }
89
+ function formatCredits(lastCheck) {
90
+ if (!lastCheck)
91
+ return c(DIM, '-');
92
+ if (lastCheck.creditsUnlimited)
93
+ return c(GREEN + BOLD, '∞');
94
+ if (lastCheck.creditsBalance == null)
95
+ return c(DIM, '-');
96
+ const balance = lastCheck.creditsBalance;
97
+ if (balance <= 0)
98
+ return c(RED, '$' + balance.toFixed(2));
99
+ return c(GREEN, '$' + balance.toFixed(2));
100
+ }
101
+ function formatPlan(plan) {
102
+ if (!plan)
103
+ return c(DIM, '-');
104
+ const upper = plan.charAt(0).toUpperCase() + plan.slice(1);
105
+ if (plan === 'plus')
106
+ return c(CYAN + BOLD, upper);
107
+ if (plan === 'team')
108
+ return c(GREEN + BOLD, upper);
109
+ if (plan === 'pro')
110
+ return c(YELLOW + BOLD, upper);
111
+ return c(WHITE + BOLD, upper);
112
+ }
113
+ function formatStatus(lastCheck) {
114
+ if (!lastCheck)
115
+ return c(DIM, 'Not checked');
116
+ if (lastCheck.ok)
117
+ return c(GREEN + BOLD, '● OK');
118
+ return c(RED + BOLD, '✗ ') + c(RED, (lastCheck.error || 'Unknown').slice(0, 30));
119
+ }
120
+ function stripAnsi(str) {
121
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
122
+ }
123
+ function visibleLength(str) {
124
+ return stripAnsi(str).length;
125
+ }
126
+ function padVisible(value, width) {
127
+ const vLen = visibleLength(value);
128
+ if (vLen >= width)
129
+ return value;
130
+ return value + ' '.repeat(width - vLen);
131
+ }
132
+ const BOX_TOP_LEFT = '┌';
133
+ const BOX_TOP_RIGHT = '┐';
134
+ const BOX_BOTTOM_LEFT = '└';
135
+ const BOX_BOTTOM_RIGHT = '┘';
136
+ const BOX_H = '─';
137
+ const BOX_V = '│';
138
+ const BOX_T_DOWN = '┬';
139
+ const BOX_T_UP = '┴';
140
+ const BOX_T_RIGHT = '├';
141
+ const BOX_T_LEFT = '┤';
142
+ const BOX_CROSS = '┼';
143
+ function renderTable(headers, rows) {
144
+ const colWidths = headers.map((header, i) => {
145
+ const headerLen = visibleLength(header);
146
+ const maxRowLen = rows.reduce((max, row) => Math.max(max, visibleLength(row[i] ?? '')), 0);
147
+ return Math.max(headerLen, maxRowLen) + 2;
148
+ });
149
+ const hLine = (left, mid, right) => left + colWidths.map(w => BOX_H.repeat(w)).join(mid) + right;
150
+ const dataLine = (parts) => BOX_V + parts.map((part, i) => ' ' + padVisible(part, colWidths[i] - 1)).join(BOX_V) + BOX_V;
151
+ const lines = [];
152
+ lines.push(c(DIM, hLine(BOX_TOP_LEFT, BOX_T_DOWN, BOX_TOP_RIGHT)));
153
+ lines.push(c(DIM, BOX_V) + headers.map((h, i) => ' ' + c(BOLD, padVisible(h, colWidths[i] - 1))).join(c(DIM, BOX_V)) + c(DIM, BOX_V));
154
+ lines.push(c(DIM, hLine(BOX_T_RIGHT, BOX_CROSS, BOX_T_LEFT)));
155
+ for (const row of rows) {
156
+ lines.push(c(DIM, BOX_V) + row.map((cell, i) => ' ' + padVisible(cell, colWidths[i] - 1)).join(c(DIM, BOX_V)) + c(DIM, BOX_V));
157
+ }
158
+ lines.push(c(DIM, hLine(BOX_BOTTOM_LEFT, BOX_T_UP, BOX_BOTTOM_RIGHT)));
159
+ return lines.join('\n');
160
+ }
161
+ export function renderAccountsTable(store) {
162
+ if (store.accounts.length === 0) {
163
+ return c(DIM, 'No accounts configured. Run `add` to get started.');
164
+ }
165
+ const rows = store.accounts.map(account => {
166
+ const isDefault = store.defaultAccountId === account.id;
167
+ const marker = isDefault ? c(GREEN + BOLD, '→') : ' ';
168
+ return [
169
+ marker,
170
+ c(isDefault ? WHITE + BOLD : WHITE, account.email || '-'),
171
+ formatUsage(account.lastCheck?.primaryUsedPercent ?? null, account.lastCheck?.primaryRemainingPercent ?? null, account.lastCheck?.primaryResetAt ?? null),
172
+ formatUsage(account.lastCheck?.secondaryUsedPercent ?? null, account.lastCheck?.secondaryRemainingPercent ?? null, account.lastCheck?.secondaryResetAt ?? null),
173
+ formatCredits(account.lastCheck),
174
+ formatStatus(account.lastCheck),
175
+ ];
176
+ });
177
+ const title = c(BOLD, `Accounts (${store.accounts.length})`);
178
+ return `${title}\n${renderTable(['', 'Email', '5h Remaining', 'Weekly Remaining', 'Credits', 'Status'], rows)}`;
179
+ }
180
+ export function printJson(value) {
181
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
182
+ }
@@ -0,0 +1,101 @@
1
+ import { loadCredentials, refreshCredentialsIfNeeded } from './auth.js';
2
+ const DEFAULT_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
3
+ const FALLBACK_USAGE_URL = 'https://chatgpt.com/api/codex/usage';
4
+ function numberOrNull(value) {
5
+ const parsed = Number(value);
6
+ return Number.isFinite(parsed) ? parsed : null;
7
+ }
8
+ function clampPercent(value) {
9
+ return Math.max(0, Math.min(100, Math.round(value)));
10
+ }
11
+ function usageHeaders(credentials) {
12
+ const headers = {
13
+ Authorization: `Bearer ${credentials.accessToken}`,
14
+ Accept: 'application/json',
15
+ 'User-Agent': 'codex-cli',
16
+ };
17
+ if (credentials.accountId) {
18
+ headers['ChatGPT-Account-Id'] = credentials.accountId;
19
+ }
20
+ return headers;
21
+ }
22
+ async function fetchUsage(credentials) {
23
+ const startedAt = Date.now();
24
+ let response = await fetch(DEFAULT_USAGE_URL, { headers: usageHeaders(credentials) });
25
+ if (response.status === 404) {
26
+ response = await fetch(FALLBACK_USAGE_URL, { headers: usageHeaders(credentials) });
27
+ }
28
+ if (response.status === 401 || response.status === 403) {
29
+ throw new Error('Unauthorized. Run relogin.');
30
+ }
31
+ if (!response.ok) {
32
+ const text = await response.text();
33
+ throw new Error(`Usage API failed with HTTP ${response.status}: ${text.slice(0, 200)}`);
34
+ }
35
+ return {
36
+ payload: await response.json(),
37
+ elapsedMs: Date.now() - startedAt,
38
+ };
39
+ }
40
+ function normalizeSuccess(account, credentials, usage) {
41
+ const primary = usage.payload.rate_limit?.primary_window;
42
+ const secondary = usage.payload.rate_limit?.secondary_window;
43
+ const credits = usage.payload.credits;
44
+ const primaryUsed = numberOrNull(primary?.used_percent);
45
+ const secondaryUsed = numberOrNull(secondary?.used_percent);
46
+ return {
47
+ ok: true,
48
+ planType: usage.payload.plan_type || credentials.planType || null,
49
+ primaryUsedPercent: primaryUsed,
50
+ primaryRemainingPercent: primaryUsed == null ? null : clampPercent(100 - primaryUsed),
51
+ primaryResetAt: primary?.reset_at ? new Date(primary.reset_at * 1000).toISOString() : null,
52
+ secondaryUsedPercent: secondaryUsed,
53
+ secondaryRemainingPercent: secondaryUsed == null ? null : clampPercent(100 - secondaryUsed),
54
+ secondaryResetAt: secondary?.reset_at ? new Date(secondary.reset_at * 1000).toISOString() : null,
55
+ creditsBalance: numberOrNull(credits?.balance),
56
+ creditsUnlimited: credits?.unlimited == null ? null : Boolean(credits.unlimited),
57
+ sourceUsed: 'oauth',
58
+ checkedAt: new Date().toISOString(),
59
+ elapsedMs: usage.elapsedMs,
60
+ error: null,
61
+ };
62
+ }
63
+ function normalizeFailure(error) {
64
+ return {
65
+ ok: false,
66
+ planType: null,
67
+ primaryUsedPercent: null,
68
+ primaryRemainingPercent: null,
69
+ primaryResetAt: null,
70
+ secondaryUsedPercent: null,
71
+ secondaryRemainingPercent: null,
72
+ secondaryResetAt: null,
73
+ creditsBalance: null,
74
+ creditsUnlimited: null,
75
+ sourceUsed: 'oauth',
76
+ checkedAt: new Date().toISOString(),
77
+ elapsedMs: 0,
78
+ error: error.message,
79
+ };
80
+ }
81
+ export async function checkAccount(account) {
82
+ try {
83
+ let credentials = loadCredentials(account.codexHome);
84
+ credentials = await refreshCredentialsIfNeeded(account.codexHome, credentials);
85
+ const usage = await fetchUsage(credentials);
86
+ return {
87
+ account: {
88
+ ...account,
89
+ email: credentials.email || account.email,
90
+ accountId: credentials.accountId || account.accountId,
91
+ },
92
+ result: normalizeSuccess(account, credentials, usage),
93
+ };
94
+ }
95
+ catch (error) {
96
+ return {
97
+ account,
98
+ result: normalizeFailure(error),
99
+ };
100
+ }
101
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@kodo/agent-meter",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "CLI tool for managing multiple Codex OAuth accounts and checking rate limits",
6
+ "bin": {
7
+ "agent-meter": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "dev": "tsx src/cli.ts",
14
+ "build": "tsc -p tsconfig.json",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "codex",
19
+ "openai",
20
+ "usage",
21
+ "rate-limit",
22
+ "cli"
23
+ ],
24
+ "author": "MeCKodo <kodoo@foxmail.com>",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/MeCKodo/agent-meter.git"
29
+ },
30
+ "dependencies": {
31
+ "commander": "^14.0.3"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^25.3.5",
35
+ "tsx": "^4.21.0",
36
+ "typescript": "^5.9.3"
37
+ }
38
+ }