pi-cliproxy-usage 0.1.0 → 0.2.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.
Files changed (5) hide show
  1. package/README.md +72 -64
  2. package/package.json +43 -28
  3. package/src/parsers.ts +82 -82
  4. package/src/ui.ts +134 -131
  5. package/src/usage.ts +131 -131
package/README.md CHANGED
@@ -1,64 +1,72 @@
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.
1
+ # pi-cliproxy-usage
2
+
3
+ Compact CLIProxyAPI account usage meters for Pi Coding Agent.
4
+
5
+ Shows one colored line below editor per enabled account:
6
+
7
+ ```text
8
+ Claude user S ███████░░░ 70% │ W ████░░░░░░ 40%
9
+ Codex user S █████████░ 90%
10
+ Grok user W █████░░░░░ 50%
11
+ ```
12
+
13
+ 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.
14
+
15
+ ## Requirements
16
+
17
+ - [Pi Coding Agent](https://github.com/earendil-works/pi)
18
+ - [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) with at least one Claude, Codex, or Grok account configured
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pi install npm:pi-cliproxy-usage
24
+ ```
25
+
26
+ After installing or updating, run `/reload` in Pi.
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.
65
+
66
+ ## Screenshot
67
+
68
+ ![CLIProxyAPI usage widget](docs/preview.png)
69
+
70
+ ## Support
71
+
72
+ Report bugs and request features in [GitHub Issues](https://github.com/Villoh/pi-cliproxy-usage/issues).
package/package.json CHANGED
@@ -1,28 +1,43 @@
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
- }
1
+ {
2
+ "name": "pi-cliproxy-usage",
3
+ "version": "0.2.0",
4
+ "description": "Compact CLIProxyAPI account usage meters for Pi Coding Agent",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "cliproxyapi",
9
+ "usage"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Villoh/pi-cliproxy-usage.git"
15
+ },
16
+ "files": [
17
+ "index.ts",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ]
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*"
29
+ },
30
+ "devDependencies": {
31
+ "@earendil-works/pi-agent-core": "0.80.10",
32
+ "@earendil-works/pi-ai": "0.80.10",
33
+ "@earendil-works/pi-coding-agent": "0.80.10",
34
+ "@earendil-works/pi-tui": "0.80.10",
35
+ "@types/node": "^24.10.0",
36
+ "tsx": "^4.20.6",
37
+ "typescript": "^5.9.3"
38
+ },
39
+ "scripts": {
40
+ "check": "tsc --noEmit",
41
+ "test": "node --import tsx --test test/**/*.test.ts"
42
+ }
43
+ }
package/src/parsers.ts CHANGED
@@ -1,82 +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
- }
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
+ }
package/src/ui.ts CHANGED
@@ -1,131 +1,134 @@
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
- }
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 separator = theme.fg("dim", " │ ");
102
+ const prefix =
103
+ theme.fg(providerColor, `● ${PROVIDER_LABELS[item.provider]}`) +
104
+ separator +
105
+ theme.fg("muted", accountLabel(item.label)) +
106
+ separator;
107
+ if (item.error) {
108
+ return truncateAnsi(
109
+ `${prefix}${theme.fg("error", `! ${item.error}`)}`,
110
+ width,
111
+ );
112
+ }
113
+ const meter = (name: string, usage: UsageWindow) => {
114
+ const used = Math.max(0, Math.min(100, usage.used));
115
+ const color =
116
+ used >= 90 ? "error" : used >= 70 ? "warning" : "success";
117
+ const filled = usageBar(used).replace(/░+$/, "");
118
+ const empty = "░".repeat(10 - filled.length);
119
+ return `${theme.fg("muted", name)} ${theme.fg(color, filled)}${theme.fg("dim", empty)} ${theme.fg(color, `${Math.round(used)}%`)}`;
120
+ };
121
+ const windows = [
122
+ item.session && meter("S", item.session),
123
+ item.weekly && meter("W", item.weekly),
124
+ ].filter(Boolean);
125
+ return truncateAnsi(
126
+ `${prefix}${windows.join(theme.fg("dim", " │ "))}`,
127
+ width,
128
+ );
129
+ });
130
+ },
131
+ }),
132
+ { placement: "belowEditor" },
133
+ );
134
+ }
package/src/usage.ts CHANGED
@@ -1,131 +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
- }
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
+ }