pi-cliproxy-usage 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 Villoh
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,64 @@
1
+ # pi-cliproxy-usage
2
+
3
+ Compact CLIProxyAPI account usage meters for Pi Coding Agent.
4
+
5
+ Code layout:
6
+
7
+ - `index.ts` — Pi lifecycle and command wiring
8
+ - `src/settings.ts` / `src/settings-ui.ts` — persisted and interactive settings
9
+ - `src/usage.ts` / `src/parsers.ts` — account discovery, provider requests, response mapping
10
+ - `src/ui.ts` — widget rendering and text formatting
11
+
12
+ Shows one colored line below editor per enabled account:
13
+
14
+ ```text
15
+ ◆ Claude user S ███████░░░ 70% │ W ████░░░░░░ 40%
16
+ ◆ Codex user S █████████░ 90%
17
+ ◆ Grok user W █████░░░░░ 50%
18
+ ```
19
+
20
+ Percentages and filled bars show usage **consumed**. Colors shift green → yellow at 70% → red at 90%. Codex intentionally shows Session only; weekly meter is omitted.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pi install ./path/to/pi-cliproxy-usage
26
+ ```
27
+
28
+ Or test directly:
29
+
30
+ ```bash
31
+ pi -e ./index.ts
32
+ ```
33
+
34
+ ## Settings
35
+
36
+ User file: `<getAgentDir()>/pi-cliproxy-usage.json` (normally `~/.pi/agent/pi-cliproxy-usage.json`). Missing file uses defaults. Changes from interactive UI apply immediately. Settings reload on session start and `/reload`.
37
+
38
+ Older `~/.pi/agent/extensions/pi-cliproxy-usage/config.json` files migrate automatically when canonical file does not exist.
39
+
40
+ ```json
41
+ {
42
+ "accountsDir": "~/.cli-proxy-api",
43
+ "refreshMinutes": 5,
44
+ "providers": {
45
+ "claude": true,
46
+ "codex": true,
47
+ "grok": true
48
+ }
49
+ }
50
+ ```
51
+
52
+ Extension reads top-level CLIProxyAPI auth JSON files with `type` equal to `claude`, `codex`, or `xai`. Disabled auth files are skipped. Credentials stay local and are sent only to official provider usage endpoints documented by OpenUsage.
53
+
54
+ Accepted values: `accountsDir` is a non-empty string, `refreshMinutes` is an integer of at least `1`, and provider values are booleans. Invalid values are ignored with a warning. Unknown fields are preserved when saving.
55
+
56
+ ## Commands
57
+
58
+ - `/cliproxy-usage` — refresh and show detailed percentages
59
+ - `/cliproxy-usage settings` — interactively edit refresh interval and provider toggles
60
+ - `/cliproxy-usage status` — show effective values, source, and settings path
61
+ - `/cliproxy-usage help` — show commands and manual settings path
62
+ - `/cliproxy-usage config` — compatibility alias for `settings`
63
+
64
+ Token refresh is deliberately left to CLIProxyAPI. If provider returns `401`/`403`, let CLIProxyAPI refresh account or log in again.
package/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionCommandContext,
4
+ type ExtensionContext,
5
+ getAgentDir,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { join } from "node:path";
8
+ import { loadSettings } from "./src/settings.js";
9
+ import { showSettings } from "./src/settings-ui.js";
10
+ import { readAccounts } from "./src/usage.js";
11
+ import { clearUsage, formatDetails, renderUsage } from "./src/ui.js";
12
+ import type { Settings } from "./src/types.js";
13
+
14
+ const commands = ["settings", "status", "help", "config"];
15
+ const SETTINGS_PATH = join(getAgentDir(), "pi-cliproxy-usage.json");
16
+ const LEGACY_SETTINGS_PATH = join(
17
+ getAgentDir(),
18
+ "extensions",
19
+ "pi-cliproxy-usage",
20
+ "config.json",
21
+ );
22
+
23
+ export default function (pi: ExtensionAPI) {
24
+ let timer: ReturnType<typeof setInterval> | undefined;
25
+ let refreshing: Promise<void> | undefined;
26
+
27
+ const refresh = (ctx: ExtensionContext, notify = false) =>
28
+ (refreshing ??= (async () => {
29
+ const loaded = await loadSettings(SETTINGS_PATH, LEGACY_SETTINGS_PATH);
30
+ if (loaded.warnings.length && ctx.hasUI) {
31
+ ctx.ui.notify(loaded.warnings.join("; "), "warning");
32
+ }
33
+ const items = await readAccounts(loaded.settings);
34
+ renderUsage(ctx, items);
35
+ if (notify) {
36
+ ctx.ui.notify(
37
+ formatDetails(items),
38
+ items.some((item) => item.error) ? "warning" : "info",
39
+ );
40
+ }
41
+ })().finally(() => {
42
+ refreshing = undefined;
43
+ }));
44
+
45
+ const scheduleRefresh = (ctx: ExtensionContext, minutes: number) => {
46
+ if (timer) clearInterval(timer);
47
+ timer = setInterval(() => void refresh(ctx), minutes * 60_000);
48
+ timer.unref?.();
49
+ };
50
+
51
+ const applySettings = async (ctx: ExtensionContext, settings: Settings) => {
52
+ scheduleRefresh(ctx, settings.refreshMinutes);
53
+ await refresh(ctx);
54
+ };
55
+
56
+ const showStatus = async (ctx: ExtensionCommandContext) => {
57
+ const loaded = await loadSettings(SETTINGS_PATH, LEGACY_SETTINGS_PATH);
58
+ const providers = Object.entries(loaded.settings.providers)
59
+ .filter(([, enabled]) => enabled)
60
+ .map(([name]) => name)
61
+ .join(", ");
62
+ ctx.ui.notify(
63
+ [
64
+ `Settings: ${loaded.path}`,
65
+ `Accounts: ${loaded.settings.accountsDir}`,
66
+ `Refresh: ${loaded.settings.refreshMinutes} min`,
67
+ `Providers: ${providers || "none"}`,
68
+ `Source: ${Object.keys(loaded.raw).length ? "settings file" : "defaults"}`,
69
+ ].join("\n"),
70
+ loaded.warnings.length ? "warning" : "info",
71
+ );
72
+ };
73
+
74
+ pi.on("session_start", async (_event, ctx) => {
75
+ const loaded = await loadSettings(SETTINGS_PATH, LEGACY_SETTINGS_PATH);
76
+ if (loaded.warnings.length && ctx.hasUI) {
77
+ ctx.ui.notify(loaded.warnings.join("; "), "warning");
78
+ }
79
+ await refresh(ctx);
80
+ scheduleRefresh(ctx, loaded.settings.refreshMinutes);
81
+ });
82
+
83
+ pi.on("session_shutdown", (_event, ctx) => {
84
+ if (timer) clearInterval(timer);
85
+ timer = undefined;
86
+ clearUsage(ctx);
87
+ });
88
+
89
+ pi.registerCommand("cliproxy-usage", {
90
+ description: "Refresh usage or manage extension settings",
91
+ getArgumentCompletions: (prefix) => {
92
+ const value = prefix.trim().toLowerCase();
93
+ const matches = commands
94
+ .filter((command) => command.startsWith(value))
95
+ .map((command) => ({ value: command, label: command }));
96
+ return matches.length ? matches : null;
97
+ },
98
+ handler: async (args, ctx) => {
99
+ const action = args.trim().toLowerCase();
100
+ if (!action) return refresh(ctx, true);
101
+ if (action === "settings" || action === "config") {
102
+ return showSettings(
103
+ ctx,
104
+ SETTINGS_PATH,
105
+ LEGACY_SETTINGS_PATH,
106
+ (settings) => applySettings(ctx, settings),
107
+ );
108
+ }
109
+ if (action === "status") return showStatus(ctx);
110
+ if (action === "help") {
111
+ ctx.ui.notify(
112
+ [
113
+ "/cliproxy-usage — refresh usage",
114
+ "/cliproxy-usage settings — edit settings",
115
+ "/cliproxy-usage status — show effective settings",
116
+ "/cliproxy-usage help — show this help",
117
+ `Manual settings: ${SETTINGS_PATH}`,
118
+ ].join("\n"),
119
+ "info",
120
+ );
121
+ return;
122
+ }
123
+ ctx.ui.notify(
124
+ `Usage: /cliproxy-usage [${commands.join("|")}]`,
125
+ "warning",
126
+ );
127
+ },
128
+ });
129
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "pi-cliproxy-usage",
3
+ "version": "0.1.0",
4
+ "description": "Compact CLIProxyAPI account usage meters for Pi Coding Agent",
5
+ "type": "module",
6
+ "keywords": ["pi-package", "cliproxyapi", "usage"],
7
+ "license": "MIT",
8
+ "files": ["index.ts", "src", "README.md"],
9
+ "pi": {
10
+ "extensions": ["./index.ts"]
11
+ },
12
+ "peerDependencies": {
13
+ "@earendil-works/pi-coding-agent": "*"
14
+ },
15
+ "devDependencies": {
16
+ "@earendil-works/pi-agent-core": "0.80.10",
17
+ "@earendil-works/pi-ai": "0.80.10",
18
+ "@earendil-works/pi-coding-agent": "0.80.10",
19
+ "@earendil-works/pi-tui": "0.80.10",
20
+ "@types/node": "^24.10.0",
21
+ "tsx": "^4.20.6",
22
+ "typescript": "^5.9.3"
23
+ },
24
+ "scripts": {
25
+ "check": "tsc --noEmit",
26
+ "test": "node --import tsx --test test/**/*.test.ts"
27
+ }
28
+ }
package/src/parsers.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { AccountUsage, UsageWindow } from "./types.js";
2
+
3
+ export function toNumber(value: unknown): number | undefined {
4
+ if (typeof value === "number" && Number.isFinite(value)) return value;
5
+ if (typeof value === "string" && value.trim()) {
6
+ const parsed = Number(value);
7
+ if (Number.isFinite(parsed)) return parsed;
8
+ }
9
+ return undefined;
10
+ }
11
+
12
+ function toDate(value: unknown): Date | undefined {
13
+ if (typeof value === "string") {
14
+ const parsed = new Date(value);
15
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
16
+ }
17
+ const raw = toNumber(value);
18
+ if (raw === undefined) return undefined;
19
+ return new Date(raw < 1e10 ? raw * 1000 : raw);
20
+ }
21
+
22
+ export function parseClaude(
23
+ body: unknown,
24
+ ): Pick<AccountUsage, "session" | "weekly"> {
25
+ const root = body as Record<string, unknown>;
26
+ const window = (value: unknown): UsageWindow | undefined => {
27
+ const item = value as Record<string, unknown> | undefined;
28
+ const used = toNumber(item?.utilization);
29
+ return used === undefined
30
+ ? undefined
31
+ : { used, resetsAt: toDate(item?.resets_at) };
32
+ };
33
+ return { session: window(root?.five_hour), weekly: window(root?.seven_day) };
34
+ }
35
+
36
+ function codexWindow(
37
+ value: unknown,
38
+ ): (UsageWindow & { seconds?: number }) | undefined {
39
+ const item = value as Record<string, unknown> | undefined;
40
+ const used = toNumber(item?.used_percent);
41
+ if (used === undefined) return undefined;
42
+ const resetAfter = toNumber(item?.reset_after_seconds);
43
+ return {
44
+ used,
45
+ seconds: toNumber(item?.limit_window_seconds),
46
+ resetsAt:
47
+ toDate(item?.reset_at) ??
48
+ (resetAfter === undefined
49
+ ? undefined
50
+ : new Date(Date.now() + resetAfter * 1000)),
51
+ };
52
+ }
53
+
54
+ export function parseCodex(body: unknown): Pick<AccountUsage, "session"> {
55
+ const rate = (body as Record<string, unknown>)?.rate_limit as
56
+ | Record<string, unknown>
57
+ | undefined;
58
+ const windows = [
59
+ codexWindow(rate?.primary_window),
60
+ codexWindow(rate?.secondary_window),
61
+ ].filter(Boolean) as Array<UsageWindow & { seconds?: number }>;
62
+ return {
63
+ session:
64
+ windows.find((item) => item.seconds === 18_000) ??
65
+ windows.find((item) => item.seconds !== 604_800) ??
66
+ windows[0],
67
+ };
68
+ }
69
+
70
+ export function parseGrok(body: unknown): Pick<AccountUsage, "weekly"> {
71
+ const config = (body as Record<string, unknown>)?.config as
72
+ | Record<string, unknown>
73
+ | undefined;
74
+ const period = config?.currentPeriod as Record<string, unknown> | undefined;
75
+ if (period?.type !== "USAGE_PERIOD_TYPE_WEEKLY") return {};
76
+ return {
77
+ weekly: {
78
+ used: toNumber(config?.creditUsagePercent) ?? 0,
79
+ resetsAt: toDate(period.end),
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ type ExtensionCommandContext,
3
+ getSettingsListTheme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ Container,
7
+ Input,
8
+ type SettingItem,
9
+ SettingsList,
10
+ Text,
11
+ } from "@earendil-works/pi-tui";
12
+ import { loadSettings, saveSettings } from "./settings.js";
13
+ import type { ProviderName, Settings } from "./types.js";
14
+
15
+ const providerIds = new Set<ProviderName>(["claude", "codex", "grok"]);
16
+
17
+ export async function showSettings(
18
+ ctx: ExtensionCommandContext,
19
+ settingsPath: string,
20
+ legacySettingsPath: string,
21
+ onChange: (settings: Settings) => Promise<void>,
22
+ ): Promise<void> {
23
+ if (ctx.mode !== "tui") {
24
+ if (ctx.hasUI)
25
+ ctx.ui.notify(`Edit settings manually: ${settingsPath}`, "info");
26
+ return;
27
+ }
28
+
29
+ const loaded = await loadSettings(settingsPath, legacySettingsPath);
30
+ if (!loaded.writable) {
31
+ ctx.ui.notify(
32
+ `Cannot edit invalid settings: ${loaded.warnings.join(", ")}`,
33
+ "error",
34
+ );
35
+ return;
36
+ }
37
+ let settings = loaded.settings;
38
+ let raw = loaded.raw;
39
+ let saveQueue = Promise.resolve();
40
+
41
+ await ctx.ui.custom((tui, theme, _keybindings, done) => {
42
+ const items: SettingItem[] = [
43
+ {
44
+ id: "accountsDir",
45
+ label: "Accounts directory",
46
+ description: "Directory containing CLIProxyAPI account JSON files",
47
+ currentValue: settings.accountsDir,
48
+ submenu: (currentValue, close) => {
49
+ const input = new Input();
50
+ input.setValue(currentValue);
51
+ input.onSubmit = (value) => close(value.trim() || undefined);
52
+ input.onEscape = () => close(undefined);
53
+ return input;
54
+ },
55
+ },
56
+ {
57
+ id: "refreshMinutes",
58
+ label: "Refresh interval",
59
+ description: "Minutes between automatic usage refreshes",
60
+ currentValue: String(settings.refreshMinutes),
61
+ values: ["1", "5", "10", "15", "30", "60"],
62
+ },
63
+ ...(["claude", "codex", "grok"] as const).map((provider) => ({
64
+ id: provider,
65
+ label: `${provider[0]?.toUpperCase()}${provider.slice(1)}`,
66
+ description: `Show ${provider} accounts`,
67
+ currentValue: settings.providers[provider] ? "enabled" : "disabled",
68
+ values: ["enabled", "disabled"],
69
+ })),
70
+ ];
71
+ const container = new Container();
72
+ container.addChild(
73
+ new Text(
74
+ theme.fg("accent", theme.bold("CLIProxyAPI Usage Settings")),
75
+ 1,
76
+ 1,
77
+ ),
78
+ );
79
+ const list = new SettingsList(
80
+ items,
81
+ Math.min(items.length + 2, 15),
82
+ getSettingsListTheme(),
83
+ (id, value) => {
84
+ const previous = structuredClone(settings);
85
+ if (id === "accountsDir") settings.accountsDir = value;
86
+ if (id === "refreshMinutes") settings.refreshMinutes = Number(value);
87
+ if (providerIds.has(id as ProviderName)) {
88
+ settings.providers[id as ProviderName] = value === "enabled";
89
+ }
90
+ const next = structuredClone(settings);
91
+ saveQueue = saveQueue
92
+ .then(async () => {
93
+ raw = await saveSettings(next, raw, settingsPath);
94
+ await onChange(next);
95
+ })
96
+ .catch((error) => {
97
+ settings = previous;
98
+ let previousValue: string;
99
+ if (id === "accountsDir") previousValue = previous.accountsDir;
100
+ else if (id === "refreshMinutes") {
101
+ previousValue = String(previous.refreshMinutes);
102
+ } else {
103
+ previousValue = previous.providers[id as ProviderName]
104
+ ? "enabled"
105
+ : "disabled";
106
+ }
107
+ list.updateValue(id, previousValue);
108
+ ctx.ui.notify(`Failed to save settings: ${error.message}`, "error");
109
+ tui.requestRender();
110
+ });
111
+ },
112
+ () => done(undefined),
113
+ { enableSearch: true },
114
+ );
115
+ container.addChild(list);
116
+ container.addChild(
117
+ new Text(
118
+ theme.fg(
119
+ "dim",
120
+ `Accounts directory: ${settings.accountsDir}\n${settingsPath}`,
121
+ ),
122
+ 1,
123
+ 1,
124
+ ),
125
+ );
126
+ return {
127
+ render: (width: number) => container.render(width),
128
+ invalidate: () => container.invalidate(),
129
+ handleInput(data: string) {
130
+ list.handleInput?.(data);
131
+ tui.requestRender();
132
+ },
133
+ };
134
+ });
135
+ await saveQueue;
136
+ }
@@ -0,0 +1,151 @@
1
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { Settings } from "./types.js";
4
+
5
+ export const DEFAULT_SETTINGS: Settings = {
6
+ accountsDir: "~/.cli-proxy-api",
7
+ refreshMinutes: 5,
8
+ providers: { claude: true, codex: true, grok: true },
9
+ };
10
+
11
+ type JsonObject = Record<string, unknown>;
12
+
13
+ export type LoadedSettings = {
14
+ settings: Settings;
15
+ raw: JsonObject;
16
+ path: string;
17
+ warnings: string[];
18
+ writable: boolean;
19
+ };
20
+
21
+ function isObject(value: unknown): value is JsonObject {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ export function normalizeSettings(value: unknown): {
26
+ settings: Settings;
27
+ raw: JsonObject;
28
+ warnings: string[];
29
+ } {
30
+ if (!isObject(value))
31
+ throw new Error("settings file must contain a JSON object");
32
+ const warnings: string[] = [];
33
+ const providers = isObject(value.providers) ? value.providers : {};
34
+ const settings: Settings = structuredClone(DEFAULT_SETTINGS);
35
+
36
+ if (typeof value.accountsDir === "string" && value.accountsDir.trim()) {
37
+ settings.accountsDir = value.accountsDir.trim();
38
+ } else if (value.accountsDir !== undefined) {
39
+ warnings.push("ignored invalid accountsDir");
40
+ }
41
+ if (
42
+ typeof value.refreshMinutes === "number" &&
43
+ Number.isInteger(value.refreshMinutes) &&
44
+ value.refreshMinutes >= 1
45
+ ) {
46
+ settings.refreshMinutes = value.refreshMinutes;
47
+ } else if (value.refreshMinutes !== undefined) {
48
+ warnings.push("ignored invalid refreshMinutes");
49
+ }
50
+ for (const provider of ["claude", "codex", "grok"] as const) {
51
+ if (typeof providers[provider] === "boolean") {
52
+ settings.providers[provider] = providers[provider];
53
+ } else if (providers[provider] !== undefined) {
54
+ warnings.push(`ignored invalid providers.${provider}`);
55
+ }
56
+ }
57
+ return { settings, raw: value, warnings };
58
+ }
59
+
60
+ async function migrateLegacyFile(
61
+ settingsPath: string,
62
+ legacyPath: string,
63
+ ): Promise<void> {
64
+ try {
65
+ await stat(settingsPath);
66
+ return;
67
+ } catch (error) {
68
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
69
+ }
70
+
71
+ let bytes: Buffer;
72
+ try {
73
+ bytes = await readFile(legacyPath);
74
+ normalizeSettings(JSON.parse(bytes.toString("utf8")));
75
+ } catch {
76
+ return;
77
+ }
78
+
79
+ await mkdir(dirname(settingsPath), { recursive: true });
80
+ try {
81
+ await writeFile(settingsPath, bytes, { flag: "wx", mode: 0o600 });
82
+ } catch (error) {
83
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
84
+ return;
85
+ }
86
+ const current = await readFile(legacyPath).catch(() => undefined);
87
+ if (current?.equals(bytes)) await rm(legacyPath);
88
+ }
89
+
90
+ export async function loadSettings(
91
+ settingsPath: string,
92
+ legacyPath: string,
93
+ ): Promise<LoadedSettings> {
94
+ await migrateLegacyFile(settingsPath, legacyPath);
95
+ let text: string;
96
+ try {
97
+ text = await readFile(settingsPath, "utf8");
98
+ } catch (error) {
99
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
100
+ return {
101
+ settings: structuredClone(DEFAULT_SETTINGS),
102
+ raw: {},
103
+ path: settingsPath,
104
+ warnings: [],
105
+ writable: true,
106
+ };
107
+ }
108
+ throw error;
109
+ }
110
+ try {
111
+ return {
112
+ ...normalizeSettings(JSON.parse(text)),
113
+ path: settingsPath,
114
+ writable: true,
115
+ };
116
+ } catch (error) {
117
+ return {
118
+ settings: structuredClone(DEFAULT_SETTINGS),
119
+ raw: {},
120
+ path: settingsPath,
121
+ warnings: [(error as Error).message],
122
+ writable: false,
123
+ };
124
+ }
125
+ }
126
+
127
+ export async function saveSettings(
128
+ settings: Settings,
129
+ raw: JsonObject,
130
+ settingsPath: string,
131
+ ): Promise<JsonObject> {
132
+ const next: JsonObject = {
133
+ ...raw,
134
+ ...settings,
135
+ providers: {
136
+ ...(isObject(raw.providers) ? raw.providers : {}),
137
+ ...settings.providers,
138
+ },
139
+ };
140
+ await mkdir(dirname(settingsPath), { recursive: true });
141
+ const temporaryPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
142
+ try {
143
+ await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
144
+ mode: 0o600,
145
+ });
146
+ await rename(temporaryPath, settingsPath);
147
+ } finally {
148
+ await rm(temporaryPath, { force: true });
149
+ }
150
+ return next;
151
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ export type ProviderName = "claude" | "codex" | "grok";
2
+
3
+ export type Settings = {
4
+ accountsDir: string;
5
+ refreshMinutes: number;
6
+ providers: Record<ProviderName, boolean>;
7
+ };
8
+
9
+ export type Config = Settings;
10
+
11
+ export type UsageWindow = {
12
+ used: number;
13
+ resetsAt?: Date;
14
+ };
15
+
16
+ export type AccountUsage = {
17
+ provider: ProviderName;
18
+ label: string;
19
+ session?: UsageWindow;
20
+ weekly?: UsageWindow;
21
+ error?: string;
22
+ };
23
+
24
+ export type Theme = {
25
+ fg(color: string, text: string): string;
26
+ };
27
+
28
+ export type UiContext = {
29
+ mode: string;
30
+ ui: {
31
+ theme: Theme;
32
+ setStatus(id: string, text: string | undefined): void;
33
+ setWidget(
34
+ id: string,
35
+ content: unknown,
36
+ options?: { placement: "belowEditor" },
37
+ ): void;
38
+ notify(message: string, level: "info" | "warning" | "error"): void;
39
+ select(title: string, options: string[]): Promise<string | undefined>;
40
+ input(title: string, placeholder?: string): Promise<string | undefined>;
41
+ };
42
+ };
package/src/ui.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type {
2
+ AccountUsage,
3
+ ProviderName,
4
+ Theme,
5
+ UiContext,
6
+ UsageWindow,
7
+ } from "./types.js";
8
+
9
+ const PROVIDER_LABELS: Record<ProviderName, string> = {
10
+ claude: "Claude",
11
+ codex: "Codex",
12
+ grok: "Grok",
13
+ };
14
+
15
+ function accountLabel(label: string): string {
16
+ const local = label.includes("@") ? label.split("@")[0] : label;
17
+ return local.slice(0, 18);
18
+ }
19
+
20
+ export function usageBar(used: number, width = 10): string {
21
+ const percent = Math.max(0, Math.min(100, used));
22
+ const filled = Math.round((percent / 100) * width);
23
+ return "█".repeat(filled) + "░".repeat(width - filled);
24
+ }
25
+
26
+ export function formatCompact(items: AccountUsage[]): string {
27
+ return items
28
+ .map((item) => {
29
+ if (item.error) {
30
+ return `${PROVIDER_LABELS[item.provider]} ${accountLabel(item.label)}: ! ${item.error}`;
31
+ }
32
+ const windows = [
33
+ item.session &&
34
+ `S ${usageBar(item.session.used)} ${Math.round(item.session.used)}%`,
35
+ item.weekly &&
36
+ `W ${usageBar(item.weekly.used)} ${Math.round(item.weekly.used)}%`,
37
+ ].filter(Boolean);
38
+ return `${PROVIDER_LABELS[item.provider]} ${accountLabel(item.label)} ${windows.join(" ") || "–"}`;
39
+ })
40
+ .join("\n");
41
+ }
42
+
43
+ export function formatDetails(items: AccountUsage[]): string {
44
+ if (!items.length) return "No enabled CLIProxyAPI accounts found.";
45
+ return items
46
+ .map((item) => {
47
+ if (item.error) return `${item.provider}/${item.label}: ${item.error}`;
48
+ const windows = [
49
+ item.session && `Session ${item.session.used.toFixed(0)}% used`,
50
+ item.weekly && `Weekly ${item.weekly.used.toFixed(0)}% used`,
51
+ ].filter(Boolean);
52
+ return `${item.provider}/${item.label}: ${windows.join(" · ") || "No usage window"}`;
53
+ })
54
+ .join("\n");
55
+ }
56
+
57
+ function truncateAnsi(text: string, width: number): string {
58
+ let visible = 0;
59
+ let result = "";
60
+ for (let index = 0; index < text.length && visible < width; ) {
61
+ if (text[index] === "\u001b") {
62
+ const match = text.slice(index).match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
63
+ if (match) {
64
+ result += match[0];
65
+ index += match[0].length;
66
+ continue;
67
+ }
68
+ }
69
+ const point = text.codePointAt(index);
70
+ if (point === undefined) break;
71
+ result += String.fromCodePoint(point);
72
+ index += point > 0xffff ? 2 : 1;
73
+ visible++;
74
+ }
75
+ return `${result}\u001b[0m`;
76
+ }
77
+
78
+ export function clearUsage(ctx: UiContext): void {
79
+ ctx.ui.setStatus("cliproxy-usage", undefined);
80
+ ctx.ui.setWidget("cliproxy-usage", undefined);
81
+ }
82
+
83
+ export function renderUsage(ctx: UiContext, items: AccountUsage[]): void {
84
+ ctx.ui.setStatus("cliproxy-usage", undefined);
85
+ if (!items.length) {
86
+ ctx.ui.setWidget("cliproxy-usage", undefined);
87
+ return;
88
+ }
89
+ ctx.ui.setWidget(
90
+ "cliproxy-usage",
91
+ (_tui: unknown, theme: Theme) => ({
92
+ invalidate() {},
93
+ render(width: number): string[] {
94
+ return items.map((item) => {
95
+ const providerColor =
96
+ item.provider === "claude"
97
+ ? "warning"
98
+ : item.provider === "codex"
99
+ ? "success"
100
+ : "accent";
101
+ const prefix =
102
+ theme.fg(providerColor, `◆ ${PROVIDER_LABELS[item.provider]}`) +
103
+ theme.fg("muted", ` ${accountLabel(item.label)}`);
104
+ if (item.error) {
105
+ return truncateAnsi(
106
+ `${prefix} ${theme.fg("error", `! ${item.error}`)}`,
107
+ width,
108
+ );
109
+ }
110
+ const meter = (name: string, usage: UsageWindow) => {
111
+ const used = Math.max(0, Math.min(100, usage.used));
112
+ const color =
113
+ used >= 90 ? "error" : used >= 70 ? "warning" : "success";
114
+ const filled = usageBar(used).replace(/░+$/, "");
115
+ const empty = "░".repeat(10 - filled.length);
116
+ return `${theme.fg("muted", name)} ${theme.fg(color, filled)}${theme.fg("dim", empty)} ${theme.fg(color, `${Math.round(used)}%`)}`;
117
+ };
118
+ const windows = [
119
+ item.session && meter("S", item.session),
120
+ item.weekly && meter("W", item.weekly),
121
+ ].filter(Boolean);
122
+ return truncateAnsi(
123
+ `${prefix} ${windows.join(theme.fg("dim", " │ "))}`,
124
+ width,
125
+ );
126
+ });
127
+ },
128
+ }),
129
+ { placement: "belowEditor" },
130
+ );
131
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { parseClaude, parseCodex, parseGrok, toNumber } from "./parsers.js";
5
+ import type { AccountUsage, Config, ProviderName } from "./types.js";
6
+
7
+ type AuthFile = {
8
+ type?: string;
9
+ email?: string;
10
+ access_token?: string;
11
+ account_id?: string;
12
+ disabled?: boolean;
13
+ };
14
+
15
+ const PROVIDERS = new Set<ProviderName>(["claude", "codex", "grok"]);
16
+ const USAGE_URLS = {
17
+ claude: "https://api.anthropic.com/api/oauth/usage",
18
+ codex: "https://chatgpt.com/backend-api/wham/usage",
19
+ grok: "https://cli-chat-proxy.grok.com/v1/billing?format=credits",
20
+ } as const;
21
+
22
+ function expandHome(path: string): string {
23
+ if (path === "~") return homedir();
24
+ if (path.startsWith("~/") || path.startsWith("~\\")) {
25
+ return join(homedir(), path.slice(2));
26
+ }
27
+ return resolve(path);
28
+ }
29
+
30
+ function providerName(type?: string): ProviderName | undefined {
31
+ const provider = type === "xai" ? "grok" : type;
32
+ return PROVIDERS.has(provider as ProviderName)
33
+ ? (provider as ProviderName)
34
+ : undefined;
35
+ }
36
+
37
+ async function request(
38
+ url: string,
39
+ token: string,
40
+ headers: Record<string, string> = {},
41
+ ): Promise<{ body: unknown; headers: Headers }> {
42
+ const response = await fetch(url, {
43
+ headers: {
44
+ Authorization: `Bearer ${token}`,
45
+ Accept: "application/json",
46
+ "User-Agent": "pi-cliproxy-usage",
47
+ ...headers,
48
+ },
49
+ signal: AbortSignal.timeout(10_000),
50
+ });
51
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
52
+ return { body: await response.json(), headers: response.headers };
53
+ }
54
+
55
+ async function fetchUsage(
56
+ provider: ProviderName,
57
+ auth: AuthFile,
58
+ file: string,
59
+ ): Promise<AccountUsage> {
60
+ const label =
61
+ auth.email || basename(file, ".json").replace(/^(claude|codex|xai)-/, "");
62
+ try {
63
+ if (!auth.access_token) throw new Error("missing access_token");
64
+ if (provider === "claude") {
65
+ const { body } = await request(USAGE_URLS.claude, auth.access_token, {
66
+ "anthropic-beta": "oauth-2025-04-20",
67
+ "Content-Type": "application/json",
68
+ });
69
+ return { provider, label, ...parseClaude(body) };
70
+ }
71
+ if (provider === "codex") {
72
+ const headers: Record<string, string> = {};
73
+ if (auth.account_id) headers["ChatGPT-Account-Id"] = auth.account_id;
74
+ const response = await request(
75
+ USAGE_URLS.codex,
76
+ auth.access_token,
77
+ headers,
78
+ );
79
+ const parsed = parseCodex(response.body);
80
+ if (!parsed.session) {
81
+ const used = toNumber(
82
+ response.headers.get("x-codex-primary-used-percent"),
83
+ );
84
+ if (used !== undefined) parsed.session = { used };
85
+ }
86
+ return { provider, label, ...parsed };
87
+ }
88
+ const { body } = await request(USAGE_URLS.grok, auth.access_token, {
89
+ "X-XAI-Token-Auth": "xai-grok-cli",
90
+ });
91
+ return { provider, label, ...parseGrok(body) };
92
+ } catch (error) {
93
+ return {
94
+ provider,
95
+ label,
96
+ error: error instanceof Error ? error.message : String(error),
97
+ };
98
+ }
99
+ }
100
+
101
+ async function readAccount(
102
+ dir: string,
103
+ name: string,
104
+ config: Config,
105
+ ): Promise<AccountUsage | undefined> {
106
+ try {
107
+ const file = join(dir, name);
108
+ const auth = JSON.parse(await readFile(file, "utf8")) as AuthFile;
109
+ const provider = providerName(auth.type);
110
+ if (!provider || auth.disabled || !config.providers[provider])
111
+ return undefined;
112
+ return fetchUsage(provider, auth, file);
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ export async function readAccounts(config: Config): Promise<AccountUsage[]> {
119
+ const dir = expandHome(config.accountsDir);
120
+ try {
121
+ const names = (await readdir(dir)).filter((name) =>
122
+ name.toLowerCase().endsWith(".json"),
123
+ );
124
+ const accounts = await Promise.all(
125
+ names.map((name) => readAccount(dir, name, config)),
126
+ );
127
+ return accounts.filter(Boolean) as AccountUsage[];
128
+ } catch {
129
+ return [];
130
+ }
131
+ }