copilot-usage-tui 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Bisov
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,83 @@
1
+ # copilot-usage-tui
2
+
3
+ A terminal UI for viewing your GitHub Copilot premium request usage.
4
+
5
+ ![Tokyo Night themed](https://img.shields.io/badge/theme-Tokyo%20Night-7aa2f7)
6
+
7
+ ## Features
8
+
9
+ - View your monthly premium request usage with a visual progress bar
10
+ - Model breakdown chart showing usage across different AI models
11
+ - Cost summary (gross, discount, net)
12
+ - End-of-month usage prediction with overage cost estimation
13
+ - Interactive plan/quota setup on first run
14
+ - Manual refresh with `r` key
15
+
16
+ ## Requirements
17
+
18
+ - [Bun](https://bun.sh/) runtime
19
+ - [GitHub CLI](https://cli.github.com/) (`gh`) authenticated with the `user` scope
20
+
21
+ ## Installation
22
+
23
+ ### Quick run (no install)
24
+
25
+ ```bash
26
+ bunx copilot-usage-tui
27
+ ```
28
+
29
+ ### Install globally
30
+
31
+ ```bash
32
+ bun install -g copilot-usage-tui
33
+ ```
34
+
35
+ Then run:
36
+
37
+ ```bash
38
+ copilot-usage-tui
39
+ ```
40
+
41
+ ### From source
42
+
43
+ ```bash
44
+ git clone https://github.com/abisov/copilot-usage-tui.git
45
+ cd copilot-usage-tui
46
+ bun install
47
+ bun run start
48
+ ```
49
+
50
+ ## Setup
51
+
52
+ Ensure your GitHub CLI has the required `user` scope:
53
+
54
+ ```bash
55
+ gh auth refresh -h github.com -s user
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ On first run, you'll be prompted to select your Copilot plan to set your monthly quota.
61
+
62
+ ### Keybindings
63
+
64
+ | Key | Action |
65
+ |-----|--------|
66
+ | `r` | Refresh usage data |
67
+ | `s` | Open settings (change plan) |
68
+ | `q` | Quit |
69
+
70
+ ## Configuration
71
+
72
+ Config is stored at `~/.copilot-usage.json` and contains your selected plan and quota.
73
+
74
+ ## How it works
75
+
76
+ Uses an undocumented GitHub billing API endpoint accessed via the `gh` CLI:
77
+ ```
78
+ gh api users/{username}/settings/billing/premium_request/usage
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "copilot-usage-tui",
3
+ "version": "1.0.0",
4
+ "description": "Terminal UI for viewing GitHub Copilot premium request usage",
5
+ "type": "module",
6
+ "bin": {
7
+ "copilot-usage-tui": "./src/index.ts"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/abisov/copilot-usage-tui.git"
12
+ },
13
+ "keywords": ["github", "copilot", "tui", "terminal", "cli", "usage"],
14
+ "author": "abisov",
15
+ "license": "MIT",
16
+ "scripts": {
17
+ "start": "bun run src/index.ts",
18
+ "dev": "bun --watch run src/index.ts"
19
+ },
20
+ "dependencies": {
21
+ "@opentui/core": "^0.1.74"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "latest",
25
+ "typescript": "^5.0.0"
26
+ }
27
+ }
@@ -0,0 +1,148 @@
1
+ import type { AuthStatus } from "../types.ts"
2
+
3
+ /**
4
+ * Check if gh CLI is installed
5
+ */
6
+ async function isGhInstalled(): Promise<boolean> {
7
+ try {
8
+ const proc = Bun.spawn(["which", "gh"], {
9
+ stdout: "pipe",
10
+ stderr: "pipe",
11
+ })
12
+ const exitCode = await proc.exited
13
+ return exitCode === 0
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Run gh auth status and parse the output
21
+ */
22
+ async function getGhAuthStatus(): Promise<{
23
+ authenticated: boolean
24
+ hasUserScope: boolean
25
+ username?: string
26
+ }> {
27
+ try {
28
+ const proc = Bun.spawn(["gh", "auth", "status"], {
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ })
32
+
33
+ // gh auth status outputs to stderr
34
+ const stderr = await new Response(proc.stderr).text()
35
+ const stdout = await new Response(proc.stdout).text()
36
+ const output = stderr + stdout
37
+ const exitCode = await proc.exited
38
+
39
+ if (exitCode !== 0) {
40
+ return { authenticated: false, hasUserScope: false }
41
+ }
42
+
43
+ // Parse username from "Logged in to github.com account USERNAME"
44
+ const usernameMatch = output.match(/Logged in to github\.com account (\w+)/)
45
+ const username = usernameMatch?.[1]
46
+
47
+ // Check for 'user' scope in token scopes
48
+ // Format: Token scopes: 'gist', 'read:org', 'repo', 'user', 'workflow'
49
+ const scopesLine = output.match(/Token scopes:\s*(.+)/)
50
+ const scopesStr = scopesLine?.[1] || ""
51
+ // Extract all quoted scope names
52
+ const scopeMatches = scopesStr.match(/'([^']+)'/g) || []
53
+ const scopes = scopeMatches.map(s => s.replace(/'/g, ""))
54
+ const hasUserScope = scopes.includes("user")
55
+
56
+ return {
57
+ authenticated: true,
58
+ hasUserScope,
59
+ username,
60
+ }
61
+ } catch {
62
+ return { authenticated: false, hasUserScope: false }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Check full authentication status
68
+ */
69
+ export async function checkAuth(): Promise<AuthStatus> {
70
+ const ghInstalled = await isGhInstalled()
71
+
72
+ if (!ghInstalled) {
73
+ return {
74
+ ghInstalled: false,
75
+ authenticated: false,
76
+ hasUserScope: false,
77
+ error: "GitHub CLI (gh) is not installed",
78
+ }
79
+ }
80
+
81
+ const { authenticated, hasUserScope, username } = await getGhAuthStatus()
82
+
83
+ if (!authenticated) {
84
+ return {
85
+ ghInstalled: true,
86
+ authenticated: false,
87
+ hasUserScope: false,
88
+ error: "Not authenticated with GitHub CLI",
89
+ }
90
+ }
91
+
92
+ if (!hasUserScope) {
93
+ return {
94
+ ghInstalled: true,
95
+ authenticated: true,
96
+ hasUserScope: false,
97
+ username,
98
+ error: "Missing 'user' scope - run: gh auth refresh -s user",
99
+ }
100
+ }
101
+
102
+ return {
103
+ ghInstalled: true,
104
+ authenticated: true,
105
+ hasUserScope: true,
106
+ username,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get auth instructions for the user
112
+ */
113
+ export function getAuthInstructions(status: AuthStatus): string[] {
114
+ if (!status.ghInstalled) {
115
+ return [
116
+ "GitHub CLI is not installed.",
117
+ "",
118
+ "Install it from: https://cli.github.com/",
119
+ "",
120
+ "Or use Homebrew:",
121
+ " brew install gh",
122
+ ]
123
+ }
124
+
125
+ if (!status.authenticated) {
126
+ return [
127
+ "GitHub CLI is not authenticated.",
128
+ "",
129
+ "Run this command in your terminal:",
130
+ " gh auth login",
131
+ "",
132
+ "Then press [r] to retry.",
133
+ ]
134
+ }
135
+
136
+ if (!status.hasUserScope) {
137
+ return [
138
+ "GitHub CLI is missing the required 'user' scope.",
139
+ "",
140
+ "Run this command in your terminal:",
141
+ " gh auth refresh -h github.com -s user",
142
+ "",
143
+ "Then press [r] to retry.",
144
+ ]
145
+ }
146
+
147
+ return ["Authentication successful!"]
148
+ }
@@ -0,0 +1,92 @@
1
+ import type { UsageResponse, UsageSummary } from "../types.ts"
2
+
3
+ /**
4
+ * Get the current authenticated username
5
+ */
6
+ export async function getUsername(): Promise<string | null> {
7
+ try {
8
+ const proc = Bun.spawn(["gh", "api", "user", "--jq", ".login"], {
9
+ stdout: "pipe",
10
+ stderr: "pipe",
11
+ })
12
+
13
+ const stdout = await new Response(proc.stdout).text()
14
+ const exitCode = await proc.exited
15
+
16
+ if (exitCode !== 0) {
17
+ return null
18
+ }
19
+
20
+ return stdout.trim()
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Fetch usage data from GitHub API
28
+ */
29
+ export async function fetchUsage(username: string): Promise<UsageResponse | null> {
30
+ try {
31
+ const proc = Bun.spawn(
32
+ ["gh", "api", `users/${username}/settings/billing/premium_request/usage`],
33
+ {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ }
37
+ )
38
+
39
+ const stdout = await new Response(proc.stdout).text()
40
+ const exitCode = await proc.exited
41
+
42
+ if (exitCode !== 0) {
43
+ return null
44
+ }
45
+
46
+ return JSON.parse(stdout) as UsageResponse
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Parse usage response into a summary
54
+ */
55
+ export function parseUsageSummary(response: UsageResponse): UsageSummary {
56
+ let totalRequests = 0
57
+ let grossAmount = 0
58
+ let discountAmount = 0
59
+ let netAmount = 0
60
+
61
+ for (const item of response.usageItems) {
62
+ totalRequests += item.grossQuantity
63
+ grossAmount += item.grossAmount
64
+ discountAmount += item.discountAmount
65
+ netAmount += item.netAmount
66
+ }
67
+
68
+ // Sort items by grossQuantity descending
69
+ const sortedItems = [...response.usageItems].sort(
70
+ (a, b) => b.grossQuantity - a.grossQuantity
71
+ )
72
+
73
+ return {
74
+ user: response.user,
75
+ year: response.timePeriod.year,
76
+ month: response.timePeriod.month,
77
+ totalRequests: Math.round(totalRequests),
78
+ grossAmount,
79
+ discountAmount,
80
+ netAmount,
81
+ items: sortedItems,
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Fetch and parse usage data
87
+ */
88
+ export async function getUsageSummary(username: string): Promise<UsageSummary | null> {
89
+ const response = await fetchUsage(username)
90
+ if (!response) return null
91
+ return parseUsageSummary(response)
92
+ }
@@ -0,0 +1,70 @@
1
+ import { homedir } from "os"
2
+ import { join } from "path"
3
+ import type { Config } from "../types.ts"
4
+
5
+ const CONFIG_FILE = join(homedir(), ".copilot-usage.json")
6
+
7
+ /**
8
+ * Check if config file exists
9
+ */
10
+ export async function hasConfig(): Promise<boolean> {
11
+ try {
12
+ const file = Bun.file(CONFIG_FILE)
13
+ return await file.exists()
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Load config from file
21
+ */
22
+ export async function loadConfig(): Promise<Config | null> {
23
+ try {
24
+ const file = Bun.file(CONFIG_FILE)
25
+ if (!(await file.exists())) {
26
+ return null
27
+ }
28
+ const text = await file.text()
29
+ const config = JSON.parse(text) as Config
30
+
31
+ // Validate config
32
+ if (typeof config.quota !== "number" || config.quota <= 0) {
33
+ return null
34
+ }
35
+ if (typeof config.plan !== "string") {
36
+ return null
37
+ }
38
+
39
+ return config
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Save config to file
47
+ */
48
+ export async function saveConfig(config: Config): Promise<void> {
49
+ const text = JSON.stringify(config, null, 2)
50
+ await Bun.write(CONFIG_FILE, text)
51
+ }
52
+
53
+ /**
54
+ * Delete config file
55
+ */
56
+ export async function deleteConfig(): Promise<void> {
57
+ try {
58
+ const { unlink } = await import("fs/promises")
59
+ await unlink(CONFIG_FILE)
60
+ } catch {
61
+ // File might not exist, that's fine
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Get config file path (for display purposes)
67
+ */
68
+ export function getConfigPath(): string {
69
+ return CONFIG_FILE
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bun
2
+ import { App } from "./ui/app.ts"
3
+
4
+ async function main() {
5
+ try {
6
+ await App.create()
7
+ } catch (error) {
8
+ console.error("Failed to start application:", error)
9
+ process.exit(1)
10
+ }
11
+ }
12
+
13
+ main()
package/src/types.ts ADDED
@@ -0,0 +1,90 @@
1
+ // GitHub API Response Types
2
+
3
+ export interface UsageItem {
4
+ product: string
5
+ sku: string
6
+ model: string
7
+ unitType: string
8
+ pricePerUnit: number
9
+ grossQuantity: number
10
+ grossAmount: number
11
+ discountQuantity: number
12
+ discountAmount: number
13
+ netQuantity: number
14
+ netAmount: number
15
+ }
16
+
17
+ export interface UsageResponse {
18
+ timePeriod: {
19
+ year: number
20
+ month: number
21
+ }
22
+ user: string
23
+ usageItems: UsageItem[]
24
+ }
25
+
26
+ // Aggregated usage data for display
27
+ export interface UsageSummary {
28
+ user: string
29
+ year: number
30
+ month: number
31
+ totalRequests: number
32
+ grossAmount: number
33
+ discountAmount: number
34
+ netAmount: number
35
+ items: UsageItem[]
36
+ }
37
+
38
+ // Config stored in ~/.copilot-usage.json
39
+ export interface Config {
40
+ quota: number
41
+ plan: string
42
+ }
43
+
44
+ // Copilot plan options
45
+ export interface PlanOption {
46
+ name: string
47
+ label: string
48
+ quota: number
49
+ description: string
50
+ }
51
+
52
+ export const PLAN_OPTIONS: PlanOption[] = [
53
+ { name: "free", label: "Free", quota: 50, description: "50 requests/month" },
54
+ { name: "pro", label: "Pro", quota: 300, description: "300 requests/month" },
55
+ { name: "pro_plus", label: "Pro+", quota: 1500, description: "1,500 requests/month" },
56
+ { name: "business", label: "Business", quota: 300, description: "300 requests/month" },
57
+ { name: "enterprise", label: "Enterprise", quota: 1000, description: "1,000 requests/month" },
58
+ { name: "custom", label: "Custom", quota: 0, description: "Enter custom value" },
59
+ ]
60
+
61
+ // Application view states
62
+ export type ViewState = "loading" | "setup" | "auth" | "dashboard" | "settings"
63
+
64
+ // Auth status
65
+ export interface AuthStatus {
66
+ ghInstalled: boolean
67
+ authenticated: boolean
68
+ hasUserScope: boolean
69
+ username?: string
70
+ error?: string
71
+ }
72
+
73
+ // Color theme (Tokyo Night)
74
+ export const THEME = {
75
+ bg: "#1a1b26",
76
+ bgDark: "#16161e",
77
+ bgHighlight: "#292e42",
78
+ border: "#3b4261",
79
+ borderHighlight: "#545c7e",
80
+ fg: "#c0caf5",
81
+ fgDark: "#a9b1d6",
82
+ fgMuted: "#565f89",
83
+ blue: "#7aa2f7",
84
+ cyan: "#7dcfff",
85
+ green: "#9ece6a",
86
+ yellow: "#e0af68",
87
+ red: "#f7768e",
88
+ magenta: "#bb9af7",
89
+ orange: "#ff9e64",
90
+ } as const