@marcosgilf/pi-usage 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +24 -2
- package/package.json +39 -4
- package/src/copilot-usage.ts +172 -0
- package/src/core.ts +108 -0
- package/src/events.ts +2 -0
- package/src/index.ts +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcos Gil
|
|
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
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 📊 pi-usage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Show GitHub Copilot plan and usage percentage in Pi's default footer, with a modal for quota details.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@marcosgilf/pi-usage
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- When installed alone, the default footer shows Copilot plan and percentage.
|
|
12
|
+
- `/copilot-usage` opens quota details.
|
|
13
|
+
- `super+shift+u` opens the same modal.
|
|
14
|
+
- In modal: `c` copies full summary; Ctrl/Cmd+C copies selected text; click outside closes.
|
|
15
|
+
- Install `@marcosgilf/pi-footer` to place usage beside the model and coordinate it with session context. It loads this extension as a dependency; do not install both packages separately.
|
|
16
|
+
|
|
17
|
+
Pi extensions run with Pi's operating-system permissions. Review package source before installing.
|
|
18
|
+
|
|
19
|
+
The quota request uses GitHub's undocumented `copilot_internal/user` endpoint through `gh`. Install and sign in to GitHub CLI. The `user` OAuth scope may be required:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
gh auth refresh -h github.com -s user
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
GitHub may change this endpoint.
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcosgilf/pi-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Show GitHub Copilot plan and quota usage in Pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"usage",
|
|
13
|
+
"quota",
|
|
14
|
+
"github-copilot",
|
|
15
|
+
"copilot"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.ts",
|
|
24
|
+
"./events": "./src/events.ts"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./src/index.ts"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@marcosgilf/pi-modal": "^0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"@earendil-works/pi-tui": "*"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type ModalContent, type ModalRow, showModal } from "@marcosgilf/pi-modal";
|
|
4
|
+
import { type CopilotUsage, formatPlanUsage, formatUsageSummary, getUsageProgress, parseCopilotUsage } from "./core.ts";
|
|
5
|
+
import { COPILOT_USAGE_STATUS_KEY, OPEN_COPILOT_USAGE_EVENT } from "./events.ts";
|
|
6
|
+
|
|
7
|
+
const CACHE_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
type UsageSnapshot = CopilotUsage & { fetchedAt: number };
|
|
10
|
+
|
|
11
|
+
let cachedUsage: UsageSnapshot | undefined;
|
|
12
|
+
let lastError: string | undefined;
|
|
13
|
+
let lastAttempt = 0;
|
|
14
|
+
let pending: Promise<UsageSnapshot> | undefined;
|
|
15
|
+
|
|
16
|
+
function runGh(args: string[]): Promise<string> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
execFile(
|
|
19
|
+
"gh",
|
|
20
|
+
["api", "--hostname", "github.com", ...args],
|
|
21
|
+
{
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
timeout: 15_000,
|
|
24
|
+
maxBuffer: 1024 * 1024,
|
|
25
|
+
},
|
|
26
|
+
(error, stdout, stderr) => {
|
|
27
|
+
if (error) {
|
|
28
|
+
const message = String(stderr || error.message).trim();
|
|
29
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
30
|
+
reject(new Error("GitHub CLI (`gh`) not found. Install it and run `gh auth login -h github.com`."));
|
|
31
|
+
} else if (/needs the ["']user["'] scope/i.test(message)) {
|
|
32
|
+
reject(
|
|
33
|
+
new Error('Copilot usage needs the "user" OAuth scope. Run `gh auth refresh -h github.com -s user`.'),
|
|
34
|
+
);
|
|
35
|
+
} else if (/not logged in|authentication required/i.test(message)) {
|
|
36
|
+
reject(new Error("GitHub CLI is not logged in. Run `gh auth login -h github.com`."));
|
|
37
|
+
} else {
|
|
38
|
+
reject(new Error(`GitHub Copilot usage request failed: ${message}`));
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
resolve(stdout.trim());
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchCopilotUsage(): Promise<CopilotUsage> {
|
|
49
|
+
return parseCopilotUsage(JSON.parse(await runGh(["copilot_internal/user"])) as unknown);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function refreshUsage(force = false): Promise<UsageSnapshot> {
|
|
53
|
+
if (pending) return pending;
|
|
54
|
+
if (!force && cachedUsage && Date.now() - cachedUsage.fetchedAt < CACHE_MS) return cachedUsage;
|
|
55
|
+
if (!force && lastError && Date.now() - lastAttempt < CACHE_MS) throw new Error(lastError);
|
|
56
|
+
|
|
57
|
+
lastAttempt = Date.now();
|
|
58
|
+
pending = fetchCopilotUsage()
|
|
59
|
+
.then((usage) => {
|
|
60
|
+
cachedUsage = { ...usage, fetchedAt: Date.now() };
|
|
61
|
+
lastError = undefined;
|
|
62
|
+
return cachedUsage;
|
|
63
|
+
})
|
|
64
|
+
.catch((error: unknown) => {
|
|
65
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
66
|
+
throw new Error(lastError);
|
|
67
|
+
})
|
|
68
|
+
.finally(() => {
|
|
69
|
+
pending = undefined;
|
|
70
|
+
});
|
|
71
|
+
return pending;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildCopilotUsageModal(usage: CopilotUsage): ModalContent {
|
|
75
|
+
const [title, planText, quotaText, resetText] = formatUsageSummary(usage);
|
|
76
|
+
const progress = getUsageProgress(usage);
|
|
77
|
+
const rows: ModalRow[] = [
|
|
78
|
+
{ text: title, style: "accent" },
|
|
79
|
+
{ text: "" },
|
|
80
|
+
{ text: planText },
|
|
81
|
+
{ text: "" },
|
|
82
|
+
{ text: quotaText },
|
|
83
|
+
];
|
|
84
|
+
if (progress === undefined) rows.push({ text: "" });
|
|
85
|
+
else rows.push({ text: "" }, { text: "", progress, style: progress >= 90 ? "warning" : "success" }, { text: "" });
|
|
86
|
+
rows.push({ text: resetText });
|
|
87
|
+
return { rows, clipboardText: [title, planText, quotaText, resetText].join("\n") };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function showCopilotUsage(ctx: ExtensionContext, isCurrent: () => boolean): Promise<void> {
|
|
91
|
+
const unavailableMessage = "/copilot-usage requires Pi TUI mode.";
|
|
92
|
+
if (ctx.mode !== "tui") {
|
|
93
|
+
ctx.ui.notify(unavailableMessage, "warning");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const usage = await refreshUsage(true);
|
|
99
|
+
if (!isCurrent()) return;
|
|
100
|
+
const content = buildCopilotUsageModal(usage);
|
|
101
|
+
ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
|
|
102
|
+
await showModal(
|
|
103
|
+
ctx,
|
|
104
|
+
content,
|
|
105
|
+
{
|
|
106
|
+
refresh: async () => {
|
|
107
|
+
const usage = await refreshUsage(true);
|
|
108
|
+
if (!isCurrent()) throw new Error("Session ended during usage refresh.");
|
|
109
|
+
ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
|
|
110
|
+
return buildCopilotUsageModal(usage);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
unavailableMessage,
|
|
114
|
+
);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (isCurrent()) ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default function (pi: ExtensionAPI): void {
|
|
121
|
+
let currentContext: ExtensionContext | undefined;
|
|
122
|
+
let sessionGeneration = 0;
|
|
123
|
+
const isCurrent = (generation: number) => () => generation === sessionGeneration;
|
|
124
|
+
|
|
125
|
+
pi.events.on(OPEN_COPILOT_USAGE_EVENT, () => {
|
|
126
|
+
if (currentContext) void showCopilotUsage(currentContext, isCurrent(sessionGeneration));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
pi.on("session_start", (_event, ctx) => {
|
|
130
|
+
const generation = ++sessionGeneration;
|
|
131
|
+
if (ctx.mode !== "tui") {
|
|
132
|
+
currentContext = undefined;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
currentContext = ctx;
|
|
136
|
+
ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(cachedUsage) || undefined);
|
|
137
|
+
void refreshUsage()
|
|
138
|
+
.then((usage) => {
|
|
139
|
+
if (generation === sessionGeneration)
|
|
140
|
+
currentContext?.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
|
|
141
|
+
})
|
|
142
|
+
.catch(() => {});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
146
|
+
if (ctx.mode !== "tui") return;
|
|
147
|
+
currentContext = ctx;
|
|
148
|
+
const generation = sessionGeneration;
|
|
149
|
+
void refreshUsage()
|
|
150
|
+
.then((usage) => {
|
|
151
|
+
if (generation === sessionGeneration)
|
|
152
|
+
currentContext?.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
|
|
153
|
+
})
|
|
154
|
+
.catch(() => {});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
158
|
+
sessionGeneration++;
|
|
159
|
+
if (ctx.mode === "tui") ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, undefined);
|
|
160
|
+
currentContext = undefined;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.registerCommand("copilot-usage", {
|
|
164
|
+
description: "Show GitHub Copilot plan and quota usage",
|
|
165
|
+
handler: (_args, ctx) => showCopilotUsage(ctx, isCurrent(sessionGeneration)),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.registerShortcut("super+shift+u", {
|
|
169
|
+
description: "Show GitHub Copilot AI-credit usage",
|
|
170
|
+
handler: (ctx) => showCopilotUsage(ctx, isCurrent(sessionGeneration)),
|
|
171
|
+
});
|
|
172
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type CopilotUsage = {
|
|
2
|
+
plan: string;
|
|
3
|
+
unit: "AI credits" | "premium requests";
|
|
4
|
+
used?: number;
|
|
5
|
+
limit?: number;
|
|
6
|
+
remaining?: number;
|
|
7
|
+
percentUsed?: number;
|
|
8
|
+
resetAt?: string;
|
|
9
|
+
unlimited: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const planNames: Record<string, string> = {
|
|
13
|
+
business: "Copilot Business",
|
|
14
|
+
enterprise: "Copilot Enterprise",
|
|
15
|
+
free: "Copilot Free",
|
|
16
|
+
individual: "Copilot Individual",
|
|
17
|
+
pro: "Copilot Pro",
|
|
18
|
+
pro_plus: "Copilot Pro+",
|
|
19
|
+
max: "Copilot Max",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function numberOrUndefined(value: unknown): number | undefined {
|
|
23
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseCopilotUsage(data: unknown): CopilotUsage {
|
|
27
|
+
if (!data || typeof data !== "object") throw new Error("GitHub returned invalid Copilot usage data.");
|
|
28
|
+
const account = data as {
|
|
29
|
+
copilot_plan?: unknown;
|
|
30
|
+
token_based_billing?: unknown;
|
|
31
|
+
quota_reset_date_utc?: unknown;
|
|
32
|
+
quota_snapshots?: { premium_interactions?: unknown };
|
|
33
|
+
};
|
|
34
|
+
const quota = account.quota_snapshots?.premium_interactions;
|
|
35
|
+
if (!quota || typeof quota !== "object") throw new Error("GitHub returned no Copilot usage quota.");
|
|
36
|
+
|
|
37
|
+
const snapshot = quota as {
|
|
38
|
+
percent_remaining?: unknown;
|
|
39
|
+
credits_used?: unknown;
|
|
40
|
+
entitlement?: unknown;
|
|
41
|
+
quota_remaining?: unknown;
|
|
42
|
+
remaining?: unknown;
|
|
43
|
+
token_based_billing?: unknown;
|
|
44
|
+
unlimited?: unknown;
|
|
45
|
+
has_quota?: unknown;
|
|
46
|
+
};
|
|
47
|
+
const limit = numberOrUndefined(snapshot.entitlement);
|
|
48
|
+
const remaining = numberOrUndefined(snapshot.quota_remaining) ?? numberOrUndefined(snapshot.remaining);
|
|
49
|
+
const used =
|
|
50
|
+
numberOrUndefined(snapshot.credits_used) ??
|
|
51
|
+
(limit !== undefined && remaining !== undefined ? Math.max(0, limit - remaining) : undefined);
|
|
52
|
+
const percentRemaining = numberOrUndefined(snapshot.percent_remaining);
|
|
53
|
+
if (percentRemaining !== undefined && percentRemaining > 100)
|
|
54
|
+
throw new Error("GitHub returned an invalid Copilot quota percentage.");
|
|
55
|
+
const ratioPercent = used !== undefined && limit && limit > 0 ? Math.round((used / limit) * 100) : undefined;
|
|
56
|
+
const percentUsed =
|
|
57
|
+
snapshot.unlimited === true || snapshot.has_quota === false
|
|
58
|
+
? undefined
|
|
59
|
+
: ratioPercent !== undefined && ratioPercent > 100
|
|
60
|
+
? ratioPercent
|
|
61
|
+
: percentRemaining !== undefined
|
|
62
|
+
? Math.round(100 - percentRemaining)
|
|
63
|
+
: ratioPercent;
|
|
64
|
+
if (percentUsed === undefined && snapshot.unlimited !== true && snapshot.has_quota !== false) {
|
|
65
|
+
throw new Error("GitHub returned no Copilot usage percentage.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rawPlan = typeof account.copilot_plan === "string" ? account.copilot_plan : "unknown";
|
|
69
|
+
const planKey = rawPlan
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
72
|
+
.replace(/^_|_$/g, "");
|
|
73
|
+
const tokenBased = snapshot.token_based_billing === true || account.token_based_billing === true;
|
|
74
|
+
return {
|
|
75
|
+
plan: planNames[planKey] ?? `Copilot ${rawPlan}`,
|
|
76
|
+
unit: tokenBased ? "AI credits" : "premium requests",
|
|
77
|
+
used,
|
|
78
|
+
limit,
|
|
79
|
+
remaining,
|
|
80
|
+
percentUsed,
|
|
81
|
+
resetAt: typeof account.quota_reset_date_utc === "string" ? account.quota_reset_date_utc : undefined,
|
|
82
|
+
unlimited: snapshot.unlimited === true || snapshot.has_quota === false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatPlanUsage(usage?: CopilotUsage): string {
|
|
87
|
+
if (!usage) return "";
|
|
88
|
+
return usage.unlimited ? `${usage.plan} ∞` : `${usage.plan} ${usage.percentUsed ?? 0}%`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatCredits(value: number | undefined): string {
|
|
92
|
+
return value === undefined ? "—" : value.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatUsageSummary(usage: CopilotUsage): [string, string, string, string] {
|
|
96
|
+
const percent = usage.percentUsed ?? 0;
|
|
97
|
+
const quota = usage.unlimited
|
|
98
|
+
? `${usage.unit}: unlimited`
|
|
99
|
+
: `${usage.unit} (resets monthly): ${formatCredits(usage.used)} / ${formatCredits(usage.limit)} (${percent}% used)`;
|
|
100
|
+
const reset = usage.resetAt
|
|
101
|
+
? `Resets: ${new Date(usage.resetAt).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} UTC`
|
|
102
|
+
: "Resets monthly";
|
|
103
|
+
return ["Copilot usage and plan", `Plan: ${usage.plan}`, quota, reset];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getUsageProgress(usage: CopilotUsage): number | undefined {
|
|
107
|
+
return usage.unlimited ? undefined : (usage.percentUsed ?? 0);
|
|
108
|
+
}
|
package/src/events.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./copilot-usage.ts";
|