pi-commandcode-usage 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/LICENSE +21 -0
- package/README.md +194 -0
- package/index.ts +458 -0
- package/package.json +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RandyLu87
|
|
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,194 @@
|
|
|
1
|
+
# pi-commandcode-usage
|
|
2
|
+
|
|
3
|
+
Persistent Command Code subscription usage line for [pi](https://pi.dev) — shows your **5-hour / weekly / monthly** quota as colored progress bars in a line **above the default footer**, auto-refreshing.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
usage 5h ▓░░░░░░░░░░░ 9% $1.32/$14 resets in 1h 33m 7d ▓░░░░░░░░░░░ 4% $1.46/$35 resets in 5d 20h mo ░░░░░░░░░░░░ 2% $1.46/$70 left $68.5 20:40
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Standalone extension: it reads pi's OAuth credential for `commandcode` and calls Command Code's alpha usage endpoints directly. It does **not** depend on or modify [`pi-commandcode-provider`](https://github.com/patlux/pi-commandcode-provider) — both can coexist.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Three quota windows in one line**: 5-hour rolling window, weekly (7-day) window, and monthly credit allocation.
|
|
16
|
+
- **Colored progress bars** (`▓` filled / `░` empty): green < 70%, yellow 70–90%, red ≥ 90%, solid red when exhausted.
|
|
17
|
+
- **Reset countdown to the minute**: `resets in 1h 33m`, `resets in 5d 20h` — same wording as Command Code's official `/usage`.
|
|
18
|
+
- **Auto-refresh**: on startup, every 60 seconds, and after every agent turn settles. A timestamp at the end of the line shows when it was last fetched.
|
|
19
|
+
- **Toggle on/off** at runtime with `/ccq-bar` — no restart needed.
|
|
20
|
+
- **Graceful failure**: on a fetch error the last good data stays on screen with an error hint; it never interrupts your work.
|
|
21
|
+
- **Default footer untouched**: your pwd / token / model line stays exactly as pi renders it.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- [pi](https://pi.dev) coding agent (interactive TUI mode)
|
|
26
|
+
- A Command Code account with a subscription (the quota endpoint is what your plan's Provider API access exposes)
|
|
27
|
+
- Logged in: run `/login` in pi and select **Command Code** (or set `COMMAND_CODE_API_KEY`)
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
### Recommended: `pi install` (one command)
|
|
34
|
+
|
|
35
|
+
This repository is a standard [pi package](https://pi.dev/packages) — install it straight from GitHub with pi's own package manager:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install git:github.com/RandyLu87/pi-commandcode-usage
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That adds it to `~/.pi/agent/settings.json` under `packages` (alongside `pi-commandcode-provider` if you have it), clones the repo to `~/.pi/agent/git/`, and loads the extension on the next startup. Restart pi or run `/reload`, and the usage line appears automatically once a `commandcode` credential is found.
|
|
42
|
+
|
|
43
|
+
Manage it like any other pi package:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pi list # show installed packages
|
|
47
|
+
pi remove git:github.com/RandyLu87/pi-commandcode-usage # uninstall
|
|
48
|
+
pi update --extensions # refresh packages (see versioning below)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Versioning
|
|
52
|
+
|
|
53
|
+
A `git:` package is **pinned to the commit it was installed at** — pi does not fetch the latest code on every startup. You choose how it tracks updates:
|
|
54
|
+
|
|
55
|
+
- **Follow `main`** (default, no `@ref`): run `pi update --extensions` to pull the latest `main`. Install spec stays unpinned, so every update moves the clone forward.
|
|
56
|
+
```bash
|
|
57
|
+
pi install git:github.com/RandyLu87/pi-commandcode-usage # unpinned: tracks main
|
|
58
|
+
pi update --extensions # pull latest main
|
|
59
|
+
```
|
|
60
|
+
- **Pin a release tag** (recommended for stability): install with `@v1.0.0` and only move when you choose to re-pin. `pi update --extensions` reconciles the clone to the pinned tag but does **not** advance past it.
|
|
61
|
+
```bash
|
|
62
|
+
pi install git:github.com/RandyLu87/pi-commandcode-usage@v1.0.0
|
|
63
|
+
# later, to move to a newer release:
|
|
64
|
+
pi install git:github.com/RandyLu87/pi-commandcode-usage@v1.1.0
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Releases are tagged `vX.Y.Z` on the main branch. Check the [tags](https://github.com/RandyLu87/pi-commandcode-usage/tags) page for the latest.
|
|
68
|
+
|
|
69
|
+
### Manual install (copy the file)
|
|
70
|
+
|
|
71
|
+
If you prefer not to use pi's package manager (or you are on Oh My Pi), copy the extension into the extensions directory:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
mkdir -p ~/.pi/agent/extensions # pi
|
|
75
|
+
# or: mkdir -p ~/.omp/agent/extensions # Oh My Pi
|
|
76
|
+
cp index.ts ~/.pi/agent/extensions/ccq-quota-bar.ts
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then restart pi / OMP, or run `/reload`.
|
|
80
|
+
|
|
81
|
+
To uninstall manually, delete the file:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
rm ~/.pi/agent/extensions/ccq-quota-bar.ts
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
The extension registers one command:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
/ccq-bar <on|off|toggle|refresh|status>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Command | What it does |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `/ccq-bar` (no arg) | same as `status` |
|
|
98
|
+
| `/ccq-bar on` | show the usage line (default) |
|
|
99
|
+
| `/ccq-bar off` | hide the usage line and stop the refresh timer |
|
|
100
|
+
| `/ccq-bar toggle` | flip between on and off |
|
|
101
|
+
| `/ccq-bar refresh` | fetch usage immediately (also refreshes the on-screen line) |
|
|
102
|
+
| `/ccq-bar status` | print config + cached quota as a notification |
|
|
103
|
+
|
|
104
|
+
`/ccq-bar status` output:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Command Code usage line
|
|
108
|
+
enabled: yes
|
|
109
|
+
credentials: found
|
|
110
|
+
last fetch: 8:40:07 PM
|
|
111
|
+
state: 5h $1.32/$14 · 7d $1.46/$35 · mo $1.46/$70 ($68.5 left)
|
|
112
|
+
usage: /ccq-bar on|off|toggle|refresh|status
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## UI
|
|
116
|
+
|
|
117
|
+
The line is rendered as a widget placed **below the input editor and above the default footer** (pi's `setWidget({ placement: "belowEditor" })`). The default footer — working directory, session name, token/cache/cost stats, model — is untouched.
|
|
118
|
+
|
|
119
|
+
### Layout
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
123
|
+
│ transcript… │
|
|
124
|
+
│ │
|
|
125
|
+
│ input editor │
|
|
126
|
+
├──────────────────────────────────────────────────────────────┤
|
|
127
|
+
│ usage 5h ▓░░░… 7d ░░░… mo ▓░… 20:40 │ ← this extension
|
|
128
|
+
├──────────────────────────────────────────────────────────────┤
|
|
129
|
+
│ ~/project (main) • mysession │ ← default footer (untouched)
|
|
130
|
+
│ ↑12.3k ↓456 R89.1k … $0.012 41%/200k (model) │
|
|
131
|
+
└──────────────────────────────────────────────────────────────┘
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Line anatomy
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
usage 5h ▓░░░░░░░░░░░ 9% $1.32/$14 resets in 1h 33m 7d ▓░░░░░░░░░░░ 4% $1.46/$35 resets in 5d 20h mo ░░░░░░░░░░░░ 2% $1.46/$70 left $68.5 20:40
|
|
138
|
+
└───┘ └window─┘ └bar + pct┘ └── used/cap ──┘ └ reset ┘ └window─┘ … └ window: used/cap + left ┘ └ last fetch time
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
| Segment | Meaning |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `usage` | fixed label |
|
|
144
|
+
| `5h` / `7d` / `mo` | quota window: rolling 5-hour, rolling 7-day, monthly allocation |
|
|
145
|
+
| `▓░░░… 9%` | progress bar (12 cells) + percent of window used |
|
|
146
|
+
| `$1.32/$14` | credits used / window cap |
|
|
147
|
+
| `resets in 1h 33m` | when the window resets (minutes precise; `5d 20h` for windows > 24 h) |
|
|
148
|
+
| `left $68.5` | monthly remaining credits (shown when cap is known) |
|
|
149
|
+
| `20:40` | last successful fetch time |
|
|
150
|
+
|
|
151
|
+
### Colors
|
|
152
|
+
|
|
153
|
+
The bar's filled cells are colored by usage ratio of that window:
|
|
154
|
+
|
|
155
|
+
| Usage | Color |
|
|
156
|
+
|---|---|
|
|
157
|
+
| < 70% | `success` (green) |
|
|
158
|
+
| 70 – 90% | `warning` (yellow) |
|
|
159
|
+
| ≥ 90% | `error` (red) |
|
|
160
|
+
| exhausted (`used ≥ cap`) | `error` (red), bar filled solid |
|
|
161
|
+
|
|
162
|
+
Colors follow your active pi theme's `success` / `warning` / `error` palette.
|
|
163
|
+
|
|
164
|
+
### Monthly cap note
|
|
165
|
+
|
|
166
|
+
Command Code's usage API returns **remaining** monthly credits but **not** the monthly cap, so the monthly denominator is mapped from your plan (see `PLAN_MONTHLY_CAP` in the source, aligned with Command Code's [pricing table](https://commandcode.ai/docs/resources/pricing-limits)). Current mappings: Go `$10`, GOAT `$70`, Pro `$80`, Max 10× `$100`, Max 20× `$200`, Team Pro `$40`. If your plan is not in the map, the monthly segment degrades to `mo left $X` (no bar) instead of guessing.
|
|
167
|
+
|
|
168
|
+
### When something goes wrong
|
|
169
|
+
|
|
170
|
+
| Situation | What you see |
|
|
171
|
+
|---|---|
|
|
172
|
+
| No `commandcode` credential | `usage fetch failed (commandcode credentials not found (run /login?)) — run /ccq-bar refresh to retry` |
|
|
173
|
+
| 401/403 from the API | `usage fetch failed (commandcode credentials rejected (401/403)) …` |
|
|
174
|
+
| Network / timeout error | last good data stays on screen; error hint appended until the next successful fetch |
|
|
175
|
+
|
|
176
|
+
## Configuration
|
|
177
|
+
|
|
178
|
+
Defaults are constants at the top of `index.ts` — edit and reload if you want to change them:
|
|
179
|
+
|
|
180
|
+
| Constant | Default | Meaning |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `REFRESH_INTERVAL_MS` | `60_000` | auto-refresh interval |
|
|
183
|
+
| `FETCH_TIMEOUT_MS` | `8_000` | per-fetch timeout |
|
|
184
|
+
| `PLAN_MONTHLY_CAP` | (see above) | plan → monthly cap map |
|
|
185
|
+
|
|
186
|
+
## Relationship to pi-commandcode-provider
|
|
187
|
+
|
|
188
|
+
[`pi-commandcode-provider`](https://github.com/patlux/pi-commandcode-provider) is the provider that lets pi talk to Command Code models, and it already ships a one-shot `/commandcode-quota` command that prints a dashboard notification.
|
|
189
|
+
|
|
190
|
+
This extension is a **companion**: it shows the same data as a persistent, auto-refreshing line so you can see your quota at all times without typing a command. It reads the same OAuth credential and hits the same alpha endpoints, but is fully standalone — no dependency on the provider package. Install both, or this one alone if you reach Command Code through another route.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Code quota bar for pi.
|
|
3
|
+
*
|
|
4
|
+
* Shows Command Code subscription usage (5h / 7d / monthly windows) as a
|
|
5
|
+
* persistent line above the default footer, with colored progress bars,
|
|
6
|
+
* auto-refreshing. Standalone: reads pi's OAuth credentials and calls
|
|
7
|
+
* Command Code's alpha usage endpoints directly; does not depend on
|
|
8
|
+
* pi-commandcode-provider internals.
|
|
9
|
+
*
|
|
10
|
+
* Line shape (example):
|
|
11
|
+
* usage 5h ▓░░░░░░░░░░░ 9% $1.20/$14 resets in 2h 12m 7d ░░░░░░░░░░░░ 4% $1.34/$35 resets in 5d 20h mo ░░░░░░░░░░░░ 2% $1.43/$70
|
|
12
|
+
*
|
|
13
|
+
* The monthly cap is not returned by the usage API; it is mapped from the
|
|
14
|
+
* plan (see PLAN_MONTHLY_CAP, official pricing table). When the plan is
|
|
15
|
+
* unrecognized, monthly shows remaining credits only, without a bar.
|
|
16
|
+
*
|
|
17
|
+
* Commands:
|
|
18
|
+
* /ccq-bar on|off|toggle show / hide the quota line
|
|
19
|
+
* /ccq-bar refresh fetch immediately
|
|
20
|
+
* /ccq-bar status print cached state and config
|
|
21
|
+
*
|
|
22
|
+
* Refresh triggers: startup, every 60 s, and after each agent turn settles.
|
|
23
|
+
* On fetch failure the last good data is kept and an error hint is shown;
|
|
24
|
+
* it never interrupts work.
|
|
25
|
+
*
|
|
26
|
+
* Progress bar: ▓ fill, colored by usage: <70% success (green), <90%
|
|
27
|
+
* warning (yellow), >=90% error (red); exhausted (used>=cap) fills solid
|
|
28
|
+
* red. Reset countdown is precise to the minute (resets in Xh Ym / Xd Yh,
|
|
29
|
+
* same wording as the official /usage).
|
|
30
|
+
*
|
|
31
|
+
* Placement: setWidget({ placement: "belowEditor" }), i.e. below the input
|
|
32
|
+
* editor and above the default footer. The default footer (pwd / token /
|
|
33
|
+
* model) is left untouched.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readFileSync } from "node:fs";
|
|
37
|
+
import { homedir } from "node:os";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
|
|
40
|
+
const CCQ_API_BASE = "https://api.commandcode.ai";
|
|
41
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
42
|
+
const FETCH_TIMEOUT_MS = 8_000;
|
|
43
|
+
const WIDGET_KEY = "ccq-quota-bar";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Monthly credit cap per plan (official pricing:
|
|
47
|
+
* commandcode.ai/docs/resources/pricing-limits). The /alpha/billing/credits
|
|
48
|
+
* endpoint only returns remaining credits (monthlyCredits), not the monthly
|
|
49
|
+
* cap, so the denominator comes from here. Unknown plans fall back to
|
|
50
|
+
* remaining-credits-only display (no bar).
|
|
51
|
+
*/
|
|
52
|
+
const PLAN_MONTHLY_CAP: Record<string, number> = {
|
|
53
|
+
// keys are matched against the subscription planId with includes()
|
|
54
|
+
"individual-go": 10, // Go $1/mo, monthly credits $10
|
|
55
|
+
"individual-goat": 70, // GOAT $10/mo, monthly credits $70
|
|
56
|
+
"individual-pro": 80, // Pro $20/mo, monthly credits $80
|
|
57
|
+
"max-10x": 100,
|
|
58
|
+
"max-20x": 200,
|
|
59
|
+
"team-pro": 40,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the monthly cap from a planId. Longest key wins so
|
|
64
|
+
* "individual-goat" is not shadowed by "individual-go".
|
|
65
|
+
*/
|
|
66
|
+
function monthlyCapForPlan(planId: string | null | undefined): number | undefined {
|
|
67
|
+
if (!planId) return undefined;
|
|
68
|
+
const id = planId.toLowerCase();
|
|
69
|
+
const matches = Object.entries(PLAN_MONTHLY_CAP)
|
|
70
|
+
.filter(([key]) => id.includes(key.toLowerCase()))
|
|
71
|
+
.sort(([a], [b]) => b.length - a.length); // most specific key first
|
|
72
|
+
return matches[0]?.[1] ?? undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shape aligned with pi-commandcode-provider's alpha endpoint (fields used only). */
|
|
76
|
+
interface CreditsResponse {
|
|
77
|
+
credits?: {
|
|
78
|
+
belowThreshold?: boolean;
|
|
79
|
+
monthlyCredits?: number;
|
|
80
|
+
purchasedCredits?: number;
|
|
81
|
+
freeCredits?: number;
|
|
82
|
+
remainingCredits?: number;
|
|
83
|
+
};
|
|
84
|
+
windowLimits?: {
|
|
85
|
+
limited?: boolean;
|
|
86
|
+
fiveHour?: { used?: number; cap?: number; resetAt?: number | null; exceeded?: boolean | null };
|
|
87
|
+
weekly?: { used?: number; cap?: number; resetAt?: number | null; exceeded?: boolean | null };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface SubscriptionResponse {
|
|
92
|
+
success?: boolean;
|
|
93
|
+
data?: {
|
|
94
|
+
planId?: string | null;
|
|
95
|
+
status?: string | null;
|
|
96
|
+
currentPeriodStart?: string | null;
|
|
97
|
+
currentPeriodEnd?: string | null;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface QuotaState {
|
|
102
|
+
ok: boolean;
|
|
103
|
+
error?: string;
|
|
104
|
+
fetchedAt?: number;
|
|
105
|
+
// monthlyCredits is the *remaining* amount; cap comes from the plan map
|
|
106
|
+
// and may be undefined when the plan is unrecognized.
|
|
107
|
+
monthlyRemaining?: number;
|
|
108
|
+
monthlyCap?: number;
|
|
109
|
+
purchased?: number;
|
|
110
|
+
free?: number;
|
|
111
|
+
fiveHour?: { used: number; cap: number; resetAt: number | null };
|
|
112
|
+
weekly?: { used: number; cap: number; resetAt: number | null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Read the OAuth access token pi stores for commandcode (same credential the provider uses). */
|
|
116
|
+
function commandCodeAccessToken(): string | undefined {
|
|
117
|
+
const candidates = [
|
|
118
|
+
process.env.COMMAND_CODE_API_KEY,
|
|
119
|
+
join(homedir(), ".pi", "agent", "auth.json"),
|
|
120
|
+
join(homedir(), ".commandcode", "auth.json"),
|
|
121
|
+
];
|
|
122
|
+
for (const c of candidates) {
|
|
123
|
+
if (!c) continue;
|
|
124
|
+
if (c.startsWith("user_") || c.startsWith("sk-")) return c; // env provided a raw key
|
|
125
|
+
try {
|
|
126
|
+
const auth = JSON.parse(readFileSync(c, "utf8"));
|
|
127
|
+
const cc = auth?.commandcode ?? auth;
|
|
128
|
+
const tok = cc?.access ?? cc?.apiKey ?? cc?.key;
|
|
129
|
+
if (typeof tok === "string" && tok.length > 8) return tok;
|
|
130
|
+
} catch {
|
|
131
|
+
/* file missing or unparsable; try the next candidate */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function fetchQuota(token: string): Promise<QuotaState> {
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
140
|
+
const headers = { accept: "application/json", Authorization: `Bearer ${token}` };
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(`${CCQ_API_BASE}/alpha/billing/credits`, {
|
|
143
|
+
headers,
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error:
|
|
150
|
+
res.status === 401 || res.status === 403
|
|
151
|
+
? "commandcode credentials rejected (401/403)"
|
|
152
|
+
: `usage endpoint HTTP ${res.status}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const data = (await res.json()) as CreditsResponse;
|
|
156
|
+
const fh = data.windowLimits?.fiveHour;
|
|
157
|
+
const wk = data.windowLimits?.weekly;
|
|
158
|
+
const monthlyRemaining = data.credits?.monthlyCredits;
|
|
159
|
+
// Fetch the subscription to get the planId for the monthly cap map.
|
|
160
|
+
// Failure here does not break the main data (degrades to no monthly bar).
|
|
161
|
+
let monthlyCap: number | undefined;
|
|
162
|
+
try {
|
|
163
|
+
const subRes = await fetch(`${CCQ_API_BASE}/alpha/billing/subscriptions`, {
|
|
164
|
+
headers,
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
});
|
|
167
|
+
if (subRes.ok) {
|
|
168
|
+
const sub = (await subRes.json()) as SubscriptionResponse;
|
|
169
|
+
monthlyCap = monthlyCapForPlan(sub.data?.planId ?? null);
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
monthlyCap = undefined;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
fetchedAt: Date.now(),
|
|
177
|
+
monthlyRemaining,
|
|
178
|
+
monthlyCap,
|
|
179
|
+
purchased: data.credits?.purchasedCredits,
|
|
180
|
+
free: data.credits?.freeCredits,
|
|
181
|
+
fiveHour:
|
|
182
|
+
fh && typeof fh.cap === "number"
|
|
183
|
+
? { used: fh.used ?? 0, cap: fh.cap, resetAt: fh.resetAt ?? null }
|
|
184
|
+
: undefined,
|
|
185
|
+
weekly:
|
|
186
|
+
wk && typeof wk.cap === "number"
|
|
187
|
+
? { used: wk.used ?? 0, cap: wk.cap, resetAt: wk.resetAt ?? null }
|
|
188
|
+
: undefined,
|
|
189
|
+
};
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Compact dollar formatting; whole numbers drop decimals ($14, not $14.0). */
|
|
198
|
+
function fmtDollar(n: number | undefined): string {
|
|
199
|
+
if (n === undefined) return "–";
|
|
200
|
+
if (n >= 100) return `$${n.toFixed(0)}`;
|
|
201
|
+
if (n >= 10) return `$${Number.isInteger(n) ? n.toFixed(0) : n.toFixed(1)}`;
|
|
202
|
+
return `$${n.toFixed(2)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Reset countdown, precise to the minute (same wording as official /usage). Under 24h: Xh Ym; over: Xd Yh. */
|
|
206
|
+
function fmtReset(resetAt: number | null): string {
|
|
207
|
+
if (!resetAt) return "";
|
|
208
|
+
const diffMs = resetAt - Date.now();
|
|
209
|
+
if (diffMs <= 0) return "resets soon";
|
|
210
|
+
const totalMinutes = Math.ceil(diffMs / 60_000);
|
|
211
|
+
if (totalMinutes < 24 * 60) {
|
|
212
|
+
const h = Math.floor(totalMinutes / 60);
|
|
213
|
+
const m = totalMinutes % 60;
|
|
214
|
+
if (h <= 0) return `resets in ${m}m`;
|
|
215
|
+
if (m === 0) return `resets in ${h}h`;
|
|
216
|
+
return `resets in ${h}h ${m}m`;
|
|
217
|
+
}
|
|
218
|
+
const days = Math.floor(totalMinutes / (24 * 60));
|
|
219
|
+
const remHours = Math.round((totalMinutes % (24 * 60)) / 60);
|
|
220
|
+
if (remHours === 24) return `resets in ${days + 1}d`;
|
|
221
|
+
if (remHours === 0) return `resets in ${days}d`;
|
|
222
|
+
return `resets in ${days}d ${remHours}h`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Draw a fixed-width progress bar.
|
|
227
|
+
* @param width bar width in characters
|
|
228
|
+
*/
|
|
229
|
+
function bar(theme: { fg(name: string, text: string): string }, used: number, cap: number, width: number): string {
|
|
230
|
+
const pct = cap > 0 ? used / cap : 0;
|
|
231
|
+
const filled = Math.min(width, Math.round(pct * width));
|
|
232
|
+
const exhausted = cap > 0 && used >= cap;
|
|
233
|
+
const color = exhausted || pct >= 0.9 ? "error" : pct >= 0.7 ? "warning" : "success";
|
|
234
|
+
const fill = theme.fg(color, "▓".repeat(exhausted ? width : filled));
|
|
235
|
+
const rest = "░".repeat(width - (exhausted ? width : filled));
|
|
236
|
+
const pctStr = `${(pct * 100).toFixed(0)}%`;
|
|
237
|
+
return `${fill}${rest} ${pctStr}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface WidgetTheme {
|
|
241
|
+
fg(name: string, text: string): string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildLine(state: QuotaState, theme: WidgetTheme): string {
|
|
245
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
246
|
+
const label = theme.fg("text", "usage");
|
|
247
|
+
if (!state.ok) {
|
|
248
|
+
const why = state.error ? ` (${state.error})` : "";
|
|
249
|
+
return `${label} ${dim(`fetch failed${why} — run /ccq-bar refresh to retry`)}`;
|
|
250
|
+
}
|
|
251
|
+
const parts: string[] = [label];
|
|
252
|
+
const fh = state.fiveHour;
|
|
253
|
+
const wk = state.weekly;
|
|
254
|
+
if (fh) {
|
|
255
|
+
parts.push(
|
|
256
|
+
`${dim("5h")} ${bar(theme, fh.used, fh.cap, 12)} ${dim(`${fmtDollar(fh.used)}/${fmtDollar(fh.cap)}${fh.resetAt ? " " + fmtReset(fh.resetAt) : ""}`)}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (wk) {
|
|
260
|
+
parts.push(
|
|
261
|
+
`${dim("7d")} ${bar(theme, wk.used, wk.cap, 12)} ${dim(`${fmtDollar(wk.used)}/${fmtDollar(wk.cap)}${wk.resetAt ? " " + fmtReset(wk.resetAt) : ""}`)}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
// Monthly: draw a bar when the cap is known (mapped from plan); otherwise
|
|
265
|
+
// show remaining credits only.
|
|
266
|
+
if (state.monthlyCap !== undefined && state.monthlyRemaining !== undefined) {
|
|
267
|
+
const used = Math.max(0, state.monthlyCap - state.monthlyRemaining);
|
|
268
|
+
parts.push(
|
|
269
|
+
`${dim("mo")} ${bar(theme, used, state.monthlyCap, 12)} ${dim(`${fmtDollar(used)}/${fmtDollar(state.monthlyCap)} left ${fmtDollar(state.monthlyRemaining)}`)}`,
|
|
270
|
+
);
|
|
271
|
+
} else if (state.monthlyRemaining !== undefined) {
|
|
272
|
+
parts.push(`${dim("mo")} ${dim(`left ${fmtDollar(state.monthlyRemaining)}`)}`);
|
|
273
|
+
}
|
|
274
|
+
if (state.fetchedAt) {
|
|
275
|
+
const t = new Date(state.fetchedAt);
|
|
276
|
+
const hh = String(t.getHours()).padStart(2, "0");
|
|
277
|
+
const mm = String(t.getMinutes()).padStart(2, "0");
|
|
278
|
+
parts.push(dim(`${hh}:${mm}`));
|
|
279
|
+
}
|
|
280
|
+
return parts.join(" ");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface CtxLike {
|
|
284
|
+
ui?: {
|
|
285
|
+
setWidget(
|
|
286
|
+
key: string,
|
|
287
|
+
content:
|
|
288
|
+
| string[]
|
|
289
|
+
| ((tui: unknown, theme: { fg(name: string, text: string): string }) => { render(): string[]; invalidate(): void })
|
|
290
|
+
| undefined,
|
|
291
|
+
options?: { placement: "aboveEditor" | "belowEditor" },
|
|
292
|
+
): void;
|
|
293
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
294
|
+
};
|
|
295
|
+
mode?: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface PiLike {
|
|
299
|
+
on(event: string, handler: (_event: unknown, ctx: CtxLike) => void | Promise<void>): void;
|
|
300
|
+
registerCommand(
|
|
301
|
+
name: string,
|
|
302
|
+
opts: {
|
|
303
|
+
description: string;
|
|
304
|
+
handler: (_args: string, ctx: CtxLike) => void | Promise<void>;
|
|
305
|
+
},
|
|
306
|
+
): void;
|
|
307
|
+
getSessionName?(): string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export default function (pi: PiLike): void {
|
|
311
|
+
let state: QuotaState = { ok: false, error: "not fetched yet" };
|
|
312
|
+
let enabled = true;
|
|
313
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
314
|
+
let inFlight: Promise<void> | null = null;
|
|
315
|
+
// TUI handle held by the current widget; used to request a repaint after
|
|
316
|
+
// a refresh so new data reaches the screen.
|
|
317
|
+
let tuiRef: { requestRender(force?: boolean): void } | null = null;
|
|
318
|
+
|
|
319
|
+
function renderNow(): void {
|
|
320
|
+
if (tuiRef) tuiRef.requestRender();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function refresh(): Promise<void> {
|
|
324
|
+
if (inFlight) return inFlight;
|
|
325
|
+
inFlight = (async () => {
|
|
326
|
+
const token = commandCodeAccessToken();
|
|
327
|
+
if (!token) {
|
|
328
|
+
state = { ok: false, error: "commandcode credentials not found (run /login?)" };
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const next = await fetchQuota(token);
|
|
332
|
+
// On failure keep the last good data and only update the error hint.
|
|
333
|
+
if (!next.ok && state.ok && state.fetchedAt) {
|
|
334
|
+
state = { ...state, error: next.error };
|
|
335
|
+
} else {
|
|
336
|
+
state = next;
|
|
337
|
+
}
|
|
338
|
+
})()
|
|
339
|
+
.catch((err) => {
|
|
340
|
+
state = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
341
|
+
})
|
|
342
|
+
.finally(() => {
|
|
343
|
+
inFlight = null;
|
|
344
|
+
renderNow();
|
|
345
|
+
});
|
|
346
|
+
return inFlight;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function updateWidget(ctx: CtxLike): void {
|
|
350
|
+
if (!ctx?.ui?.setWidget) return;
|
|
351
|
+
if (!enabled) {
|
|
352
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
ctx.ui.setWidget(
|
|
356
|
+
WIDGET_KEY,
|
|
357
|
+
(_tui, theme) => {
|
|
358
|
+
tuiRef = _tui as { requestRender(force?: boolean): void };
|
|
359
|
+
return {
|
|
360
|
+
render: () => [buildLine(state, theme as { fg(name: string, text: string): string })],
|
|
361
|
+
invalidate: () => {},
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
{ placement: "belowEditor" },
|
|
365
|
+
);
|
|
366
|
+
renderNow(); // make the first content appear immediately
|
|
367
|
+
void refresh();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Interval refresh only needs to trigger a repaint; the token is read per fetch.
|
|
371
|
+
function startTimer(): void {
|
|
372
|
+
if (timer) clearInterval(timer);
|
|
373
|
+
timer = setInterval(() => {
|
|
374
|
+
void refresh();
|
|
375
|
+
}, REFRESH_INTERVAL_MS);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
pi.registerCommand("ccq-bar", {
|
|
379
|
+
description: "Command Code usage line: on|off|toggle|refresh|status",
|
|
380
|
+
handler: async (args, ctx) => {
|
|
381
|
+
const cmd = (args || "").trim().split(/\s+/)[0] || "status";
|
|
382
|
+
if (cmd === "on") {
|
|
383
|
+
enabled = true;
|
|
384
|
+
updateWidget(ctx);
|
|
385
|
+
startTimer();
|
|
386
|
+
ctx.ui?.notify?.("Command Code usage line enabled", "info");
|
|
387
|
+
} else if (cmd === "off") {
|
|
388
|
+
enabled = false;
|
|
389
|
+
if (timer) clearInterval(timer);
|
|
390
|
+
timer = null;
|
|
391
|
+
ctx.ui?.setWidget?.(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
392
|
+
ctx.ui?.notify?.("Command Code usage line disabled", "info");
|
|
393
|
+
} else if (cmd === "toggle") {
|
|
394
|
+
if (enabled) {
|
|
395
|
+
enabled = false;
|
|
396
|
+
if (timer) clearInterval(timer);
|
|
397
|
+
timer = null;
|
|
398
|
+
ctx.ui?.setWidget?.(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
399
|
+
ctx.ui?.notify?.("Command Code usage line disabled", "info");
|
|
400
|
+
} else {
|
|
401
|
+
enabled = true;
|
|
402
|
+
updateWidget(ctx);
|
|
403
|
+
startTimer();
|
|
404
|
+
ctx.ui?.notify?.("Command Code usage line enabled", "info");
|
|
405
|
+
}
|
|
406
|
+
} else if (cmd === "refresh") {
|
|
407
|
+
await refresh();
|
|
408
|
+
renderNow();
|
|
409
|
+
ctx.ui?.notify?.(
|
|
410
|
+
state.ok ? "Command Code usage refreshed" : `Command Code usage refresh failed: ${state.error}`,
|
|
411
|
+
state.ok ? "info" : "error",
|
|
412
|
+
);
|
|
413
|
+
} else {
|
|
414
|
+
// status
|
|
415
|
+
const cred = commandCodeAccessToken() ? "found" : "missing";
|
|
416
|
+
const lines = [
|
|
417
|
+
"Command Code usage line",
|
|
418
|
+
` enabled: ${enabled ? "yes" : "no"}`,
|
|
419
|
+
` credentials: ${cred}`,
|
|
420
|
+
` last fetch: ${state.ok ? (state.fetchedAt ? new Date(state.fetchedAt).toLocaleTimeString() : "none") : "none"}`,
|
|
421
|
+
` state: ${state.ok
|
|
422
|
+
? state.fetchedAt
|
|
423
|
+
? (() => {
|
|
424
|
+
const fh = state.fiveHour ? `${fmtDollar(state.fiveHour.used)}/${fmtDollar(state.fiveHour.cap)}` : "–";
|
|
425
|
+
const wk = state.weekly ? `${fmtDollar(state.weekly.used)}/${fmtDollar(state.weekly.cap)}` : "–";
|
|
426
|
+
const mo =
|
|
427
|
+
state.monthlyCap !== undefined && state.monthlyRemaining !== undefined
|
|
428
|
+
? `${fmtDollar(Math.max(0, state.monthlyCap - state.monthlyRemaining))}/${fmtDollar(state.monthlyCap)} (${fmtDollar(state.monthlyRemaining)} left)`
|
|
429
|
+
: state.monthlyRemaining !== undefined
|
|
430
|
+
? `${fmtDollar(state.monthlyRemaining)} left (plan cap not mapped)`
|
|
431
|
+
: "–";
|
|
432
|
+
return `5h ${fh} · 7d ${wk} · mo ${mo}`;
|
|
433
|
+
})()
|
|
434
|
+
: "waiting for first fetch"
|
|
435
|
+
: `failed: ${state.error ?? ""}`}`,
|
|
436
|
+
" usage: /ccq-bar on|off|toggle|refresh|status",
|
|
437
|
+
];
|
|
438
|
+
ctx.ui?.notify?.(lines.join("\n"), "info");
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
pi.on("session_start", (_event, ctx) => {
|
|
444
|
+
updateWidget(ctx);
|
|
445
|
+
startTimer();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Refresh after each agent turn settles so usage stays close to live.
|
|
449
|
+
pi.on("agent_settled", (_event, _ctx) => {
|
|
450
|
+
if (!enabled) return;
|
|
451
|
+
void refresh();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
pi.on("session_shutdown", () => {
|
|
455
|
+
if (timer) clearInterval(timer);
|
|
456
|
+
timer = null;
|
|
457
|
+
});
|
|
458
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-commandcode-usage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Persistent Command Code usage line (5h / weekly / monthly quota bars) for the pi coding agent — auto-refreshing widget above the default footer",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"commandcode",
|
|
9
|
+
"usage",
|
|
10
|
+
"quota"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"files": [
|
|
14
|
+
"index.ts",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./index.ts"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|