copilot-spend 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """copilot-spend: a one-shot CLI for current-period GitHub Copilot spend."""
@@ -0,0 +1,4 @@
1
+ from copilot_spend.cli import _entrypoint
2
+
3
+ if __name__ == "__main__":
4
+ _entrypoint()
copilot_spend/api.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import ssl
6
+ import urllib.error
7
+ import urllib.request
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ from copilot_spend.auth import Auth
11
+ from copilot_spend.paths import scrub
12
+ from copilot_spend.quota import NoSubscriptionError
13
+
14
+
15
+ class APIError(Exception):
16
+ pass
17
+
18
+
19
+ def _package_version() -> str:
20
+ try:
21
+ return version("copilot-spend")
22
+ except PackageNotFoundError:
23
+ return "dev"
24
+
25
+
26
+ def _build_url(host: str) -> str:
27
+ if host == "github.com":
28
+ return "https://api.github.com/copilot_internal/user"
29
+ return f"https://{host}/api/v3/copilot_internal/user"
30
+
31
+
32
+ def _body_excerpt(raw: bytes, limit: int = 500) -> str:
33
+ try:
34
+ text = raw.decode("utf-8", errors="replace")
35
+ except Exception:
36
+ text = "<unreadable response body>"
37
+ text = text.strip()
38
+ if len(text) > limit:
39
+ text = text[:limit] + "…"
40
+ return text
41
+
42
+
43
+ def _reauth_message(source: str) -> str:
44
+ if source == "native":
45
+ return (
46
+ "Token rejected by GitHub Copilot. "
47
+ "Run `copilot-spend login` to re-authenticate."
48
+ )
49
+ return (
50
+ "Token rejected by GitHub Copilot — opencode token may be expired. "
51
+ "Run `opencode login` to refresh."
52
+ )
53
+
54
+
55
+ def fetch_quota(auth: Auth, *, timeout: float = 10.0) -> dict:
56
+ # `/copilot_internal/user` accepts the OAuth/GitHub-App user token
57
+ # directly as Bearer for both `ghu_` (native) and `gho_` (opencode).
58
+ # No session-token exchange — that token (`/copilot_internal/v2/token`)
59
+ # is for the Copilot Chat proxy at api.githubcopilot.com, not this endpoint.
60
+ url = _build_url(auth.host)
61
+ request = urllib.request.Request(
62
+ url,
63
+ headers={
64
+ "Authorization": f"Bearer {auth.token}",
65
+ "User-Agent": f"copilot-spend/{_package_version()}",
66
+ "Accept": "application/json",
67
+ },
68
+ method="GET",
69
+ )
70
+
71
+ context = ssl.create_default_context()
72
+
73
+ try:
74
+ with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
75
+ raw = response.read()
76
+ except urllib.error.HTTPError as exc:
77
+ status = exc.code
78
+ if status in (401, 403):
79
+ raise APIError(_reauth_message(auth.source)) from None
80
+ if status == 404:
81
+ raise NoSubscriptionError(
82
+ "No Copilot quota on this account."
83
+ ) from None
84
+ try:
85
+ body = scrub(_body_excerpt(exc.read()), auth.token)
86
+ except Exception:
87
+ body = "<no response body>"
88
+ if 500 <= status < 600:
89
+ raise APIError(
90
+ f"GitHub Copilot API returned {status} at {url} — try again shortly."
91
+ ) from None
92
+ raise APIError(
93
+ f"GitHub Copilot API returned {status} at {url}: {body}"
94
+ ) from None
95
+ except TimeoutError:
96
+ raise APIError(
97
+ f"Request to {auth.host} timed out after {timeout}s."
98
+ ) from None
99
+ except socket.timeout:
100
+ raise APIError(
101
+ f"Request to {auth.host} timed out after {timeout}s."
102
+ ) from None
103
+ except urllib.error.URLError as exc:
104
+ underlying = getattr(exc, "reason", exc)
105
+ raise APIError(
106
+ f"Could not reach {auth.host}: {underlying}"
107
+ ) from None
108
+
109
+ try:
110
+ return json.loads(raw.decode("utf-8"))
111
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
112
+ raise APIError(
113
+ f"GitHub Copilot API at {url} returned a non-JSON response: {exc}"
114
+ ) from None
copilot_spend/auth.py ADDED
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import os
6
+ import re
7
+ import stat
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ AUTH_PATH = Path.home() / ".local/share/opencode/auth.json"
14
+
15
+ # RFC 1123 hostname: dot-separated labels, each 1-63 chars of [A-Za-z0-9-],
16
+ # not starting or ending with a hyphen. Total length ≤ 253.
17
+ _HOSTNAME_LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
18
+ _HOSTNAME_RE = re.compile(rf"^{_HOSTNAME_LABEL}(?:\.{_HOSTNAME_LABEL})*$")
19
+
20
+
21
+ class AuthError(Exception):
22
+ pass
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Auth:
27
+ token: str
28
+ host: str
29
+ source: Literal["native", "opencode"] = "opencode"
30
+
31
+ def __repr__(self) -> str:
32
+ return f"Auth(token=<redacted>, host={self.host!r}, source={self.source!r})"
33
+
34
+
35
+ def _is_private_ip_literal(host: str) -> bool:
36
+ try:
37
+ addr = ipaddress.ip_address(host)
38
+ except ValueError:
39
+ return False
40
+ return (
41
+ addr.is_private
42
+ or addr.is_loopback
43
+ or addr.is_link_local
44
+ or addr.is_reserved
45
+ or addr.is_multicast
46
+ or addr.is_unspecified
47
+ )
48
+
49
+
50
+ def is_valid_host(host: str) -> bool:
51
+ if not host or len(host) > 253:
52
+ return False
53
+ if _is_private_ip_literal(host):
54
+ return False
55
+ return bool(_HOSTNAME_RE.match(host))
56
+
57
+
58
+ def normalize_host(raw: str) -> str:
59
+ host = raw.strip()
60
+ for prefix in ("https://", "http://"):
61
+ if host.startswith(prefix):
62
+ host = host[len(prefix):]
63
+ break
64
+ return host.rstrip("/").strip()
65
+
66
+
67
+ # Backwards-compat aliases retained for module-internal use.
68
+ _is_valid_host = is_valid_host
69
+ _normalize_host = normalize_host
70
+
71
+
72
+ def _warn_if_permissive(path: Path) -> None:
73
+ if os.name != "posix":
74
+ return
75
+ try:
76
+ mode = path.stat().st_mode
77
+ except OSError:
78
+ return
79
+ permissive_bits = mode & (stat.S_IRWXG | stat.S_IRWXO)
80
+ if permissive_bits:
81
+ owner_octal = stat.S_IMODE(mode)
82
+ print(
83
+ f"warning: {path} permissions are {oct(owner_octal)} — "
84
+ "the file holds a long-lived Copilot token; consider `chmod 600`.",
85
+ file=sys.stderr,
86
+ )
87
+
88
+
89
+ def _read_opencode(path: Path) -> Auth | None:
90
+ if not path.exists():
91
+ return None
92
+
93
+ _warn_if_permissive(path)
94
+
95
+ try:
96
+ data = json.loads(path.read_text(encoding="utf-8"))
97
+ except json.JSONDecodeError as exc:
98
+ raise AuthError(f"opencode auth file is malformed JSON at {path}: {exc.msg}") from None
99
+
100
+ if not isinstance(data, dict):
101
+ raise AuthError(f"opencode auth file at {path} is not a JSON object.")
102
+
103
+ entry = data.get("github-copilot") or {}
104
+ if not isinstance(entry, dict):
105
+ raise AuthError(
106
+ f"opencode auth file at {path} has a malformed github-copilot entry."
107
+ )
108
+
109
+ token = entry.get("access")
110
+ if not token or not isinstance(token, str):
111
+ raise AuthError(
112
+ f"No GitHub Copilot token in {path}. Run `opencode login` first."
113
+ )
114
+
115
+ enterprise_raw = entry.get("enterpriseUrl")
116
+ if enterprise_raw is None:
117
+ enterprise_raw = ""
118
+ if not isinstance(enterprise_raw, str):
119
+ raise AuthError(
120
+ f"opencode auth file at {path} has a non-string enterpriseUrl field."
121
+ )
122
+
123
+ enterprise = normalize_host(enterprise_raw)
124
+ if not enterprise:
125
+ host = "github.com"
126
+ else:
127
+ if not is_valid_host(enterprise):
128
+ raise AuthError(
129
+ f"enterpriseUrl in {path} is not a valid hostname: {enterprise_raw!r}. "
130
+ "Expected a bare hostname like `ghe.example.com`."
131
+ )
132
+ host = enterprise
133
+
134
+ return Auth(token=token, host=host, source="opencode")
135
+
136
+
137
+ def _read_native(path: Path) -> Auth | None:
138
+ if not path.exists():
139
+ return None
140
+
141
+ _warn_if_permissive(path)
142
+
143
+ try:
144
+ data = json.loads(path.read_text(encoding="utf-8"))
145
+ except json.JSONDecodeError as exc:
146
+ raise AuthError(
147
+ f"copilot-spend auth file is malformed JSON at {path}: {exc.msg}. "
148
+ "Run `copilot-spend login` to recreate it."
149
+ ) from None
150
+
151
+ if not isinstance(data, dict):
152
+ raise AuthError(f"copilot-spend auth file at {path} is not a JSON object.")
153
+
154
+ entry = data.get("github-copilot") or {}
155
+ if not isinstance(entry, dict):
156
+ raise AuthError(
157
+ f"copilot-spend auth file at {path} has a malformed github-copilot entry."
158
+ )
159
+
160
+ token = entry.get("token")
161
+ if not token or not isinstance(token, str):
162
+ raise AuthError(
163
+ f"No GitHub Copilot token in {path}. Run `copilot-spend login`."
164
+ )
165
+
166
+ host_raw = entry.get("host", "")
167
+ if not isinstance(host_raw, str):
168
+ raise AuthError(
169
+ f"copilot-spend auth file at {path} has a non-string host field."
170
+ )
171
+
172
+ host = normalize_host(host_raw) or "github.com"
173
+ if host != "github.com" and not is_valid_host(host):
174
+ raise AuthError(
175
+ f"host in {path} is not a valid hostname: {host_raw!r}."
176
+ )
177
+
178
+ return Auth(token=token, host=host, source="native")
179
+
180
+
181
+ def resolve_auth(
182
+ *,
183
+ native_path: Path | None = None,
184
+ opencode_path: Path = AUTH_PATH,
185
+ ) -> Auth:
186
+ if native_path is None:
187
+ # Defer the import to call time so test monkeypatching of
188
+ # COPILOT_SPEND_CONFIG_DIR is honored.
189
+ from . import paths as _paths
190
+ native_path = _paths.auth_path()
191
+
192
+ native = _read_native(native_path)
193
+ if native is not None:
194
+ return native
195
+
196
+ opencode = _read_opencode(opencode_path)
197
+ if opencode is not None:
198
+ return opencode
199
+
200
+ raise AuthError(
201
+ "No credentials found. Run `copilot-spend login` to authenticate, "
202
+ "or install opencode and run `opencode login`."
203
+ )
copilot_spend/cli.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ from copilot_spend.api import APIError, fetch_quota
9
+ from copilot_spend.auth import AuthError, resolve_auth
10
+ from copilot_spend.output import render
11
+ from copilot_spend.paths import scrub
12
+ from copilot_spend.quota import NoSubscriptionError, parse_quota
13
+
14
+
15
+ def _package_version() -> str:
16
+ try:
17
+ return version("copilot-spend")
18
+ except PackageNotFoundError:
19
+ return "dev"
20
+
21
+
22
+ def _build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="copilot-spend",
25
+ description="Print your current-period GitHub Copilot spend and reset date.",
26
+ )
27
+ parser.add_argument(
28
+ "--version",
29
+ action="version",
30
+ version=f"copilot-spend {_package_version()}",
31
+ )
32
+ subparsers = parser.add_subparsers(dest="command")
33
+ subparsers.add_parser(
34
+ "login",
35
+ help="Authenticate via GitHub OAuth device flow (github.com or GHE).",
36
+ )
37
+ subparsers.add_parser(
38
+ "logout",
39
+ help="Remove copilot-spend's stored credentials.",
40
+ )
41
+ return parser
42
+
43
+
44
+ def _run_show_quota() -> int:
45
+ auth = None
46
+ try:
47
+ auth = resolve_auth()
48
+ except AuthError as exc:
49
+ print(str(exc), file=sys.stderr)
50
+ return 2
51
+
52
+ try:
53
+ payload = fetch_quota(auth)
54
+ except NoSubscriptionError as exc:
55
+ print(f"no Copilot quota on this account: {exc}", file=sys.stderr)
56
+ return 4
57
+ except APIError as exc:
58
+ print(scrub(str(exc), auth.token if auth else None), file=sys.stderr)
59
+ return 3
60
+
61
+ try:
62
+ spend = parse_quota(payload)
63
+ except NoSubscriptionError as exc:
64
+ print(f"no Copilot quota on this account: {exc}", file=sys.stderr)
65
+ return 4
66
+
67
+ try:
68
+ print(render(spend, now=datetime.now(timezone.utc)))
69
+ except Exception as exc:
70
+ print(
71
+ scrub(f"unexpected error rendering output: {exc}", auth.token if auth else None),
72
+ file=sys.stderr,
73
+ )
74
+ return 1
75
+
76
+ return 0
77
+
78
+
79
+ def main(argv: list[str] | None = None) -> int:
80
+ parser = _build_parser()
81
+ args = parser.parse_args(argv)
82
+
83
+ if args.command == "login":
84
+ from copilot_spend.login import run_login
85
+ return run_login()
86
+ if args.command == "logout":
87
+ from copilot_spend.login import run_logout
88
+ return run_logout()
89
+
90
+ return _run_show_quota()
91
+
92
+
93
+ def _entrypoint() -> None:
94
+ try:
95
+ sys.exit(main())
96
+ except SystemExit:
97
+ raise
98
+ except Exception as exc:
99
+ print(f"unexpected error: {exc}", file=sys.stderr)
100
+ sys.exit(1)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ _entrypoint()
copilot_spend/login.py ADDED
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import ssl
6
+ import sys
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ from . import api, paths
12
+ from .auth import Auth, AuthError, is_valid_host, normalize_host
13
+ from .quota import NoSubscriptionError
14
+
15
+ CLIENT_ID = "Iv1.b507a08c87ecfe98"
16
+ OAUTH_SCOPE = "read:user"
17
+ POLL_MAX_WAIT_S = 900
18
+
19
+
20
+ def _device_url(host: str) -> str:
21
+ if host == "github.com":
22
+ return "https://github.com/login/device/code"
23
+ return f"https://{host}/login/device/code"
24
+
25
+
26
+ def _access_token_url(host: str) -> str:
27
+ if host == "github.com":
28
+ return "https://github.com/login/oauth/access_token"
29
+ return f"https://{host}/login/oauth/access_token"
30
+
31
+
32
+ def _post_json(url: str, body: dict, *, timeout: float = 10.0) -> dict:
33
+ request = urllib.request.Request(
34
+ url,
35
+ data=json.dumps(body).encode("utf-8"),
36
+ headers={
37
+ "Accept": "application/json",
38
+ "Content-Type": "application/json",
39
+ },
40
+ method="POST",
41
+ )
42
+ context = ssl.create_default_context()
43
+ with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
44
+ raw = response.read()
45
+ return json.loads(raw.decode("utf-8"))
46
+
47
+
48
+ def _prompt_host(stdin=None, stderr=None) -> str:
49
+ stdin = stdin or sys.stdin
50
+ stderr = stderr or sys.stderr
51
+ print("Where do you authenticate?", file=stderr)
52
+ print(" 1) github.com", file=stderr)
53
+ print(" 2) GitHub Enterprise", file=stderr)
54
+ print("Choose 1 or 2 [1]: ", end="", file=stderr, flush=True)
55
+ choice = stdin.readline().strip() or "1"
56
+
57
+ if choice == "1":
58
+ return "github.com"
59
+ if choice != "2":
60
+ raise AuthError(f"Unrecognized choice: {choice!r}. Expected 1 or 2.")
61
+
62
+ print("GHE host (e.g. ghe.example.com): ", end="", file=stderr, flush=True)
63
+ raw = stdin.readline().strip()
64
+ host = normalize_host(raw)
65
+ if not is_valid_host(host):
66
+ raise AuthError(
67
+ f"Not a valid hostname: {raw!r}. Expected a bare hostname like `ghe.example.com`."
68
+ )
69
+ return host
70
+
71
+
72
+ def _request_device_code(host: str) -> dict:
73
+ url = _device_url(host)
74
+ try:
75
+ payload = _post_json(url, {"client_id": CLIENT_ID, "scope": OAUTH_SCOPE})
76
+ except urllib.error.HTTPError as exc:
77
+ raise AuthError(
78
+ f"GitHub returned {exc.code} from {url}. Check the host and try again."
79
+ ) from None
80
+ except (TimeoutError, socket.timeout):
81
+ raise AuthError(f"Timed out reaching {host} for device-code request.") from None
82
+ except urllib.error.URLError as exc:
83
+ underlying = getattr(exc, "reason", exc)
84
+ raise AuthError(f"Could not reach '{host}': {underlying}.") from None
85
+
86
+ if not isinstance(payload, dict):
87
+ raise AuthError(f"Unexpected response from {url}: not a JSON object.")
88
+ error = payload.get("error")
89
+ if error:
90
+ description = payload.get("error_description", error)
91
+ raise AuthError(
92
+ f"GitHub rejected the device-code request ({error}): {description}."
93
+ )
94
+
95
+ required = ("device_code", "user_code", "verification_uri", "interval")
96
+ if not all(payload.get(k) for k in required):
97
+ raise AuthError(
98
+ f"Device-code response from {url} is missing required fields."
99
+ )
100
+ return payload
101
+
102
+
103
+ def _poll_for_token(
104
+ host: str,
105
+ device_code: str,
106
+ interval: int,
107
+ *,
108
+ now=time.monotonic,
109
+ sleep=time.sleep,
110
+ max_wait: int = POLL_MAX_WAIT_S,
111
+ ) -> str:
112
+ url = _access_token_url(host)
113
+ body = {
114
+ "client_id": CLIENT_ID,
115
+ "device_code": device_code,
116
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
117
+ }
118
+ started = now()
119
+ current_interval = max(int(interval), 1)
120
+
121
+ while True:
122
+ if now() - started > max_wait:
123
+ raise AuthError("Login timed out. Run `copilot-spend login` again.")
124
+
125
+ sleep(current_interval)
126
+
127
+ try:
128
+ payload = _post_json(url, body)
129
+ except urllib.error.HTTPError as exc:
130
+ raise AuthError(
131
+ f"GitHub returned {exc.code} during token polling. Run `copilot-spend login` again."
132
+ ) from None
133
+ except (TimeoutError, socket.timeout):
134
+ # Transient — keep polling within the time budget.
135
+ continue
136
+ except urllib.error.URLError as exc:
137
+ underlying = getattr(exc, "reason", exc)
138
+ raise AuthError(f"Could not reach '{host}': {underlying}.") from None
139
+
140
+ if not isinstance(payload, dict):
141
+ raise AuthError("Unexpected non-object response from token endpoint.")
142
+
143
+ token = payload.get("access_token")
144
+ if token:
145
+ if not isinstance(token, str):
146
+ raise AuthError("GitHub returned a non-string access_token.")
147
+ return token
148
+
149
+ err = payload.get("error")
150
+ if err == "authorization_pending":
151
+ continue
152
+ if err == "slow_down":
153
+ current_interval += 5
154
+ continue
155
+ if err == "expired_token":
156
+ raise AuthError("Login timed out. Run `copilot-spend login` again.")
157
+ if err == "access_denied":
158
+ raise AuthError("Authorization denied. Run `copilot-spend login` again to retry.")
159
+ if err == "unauthorized_client":
160
+ raise AuthError(
161
+ "GitHub rejected the OAuth app (unauthorized_client). "
162
+ "The bundled CLIENT_ID may not be allowed on this host. "
163
+ "See README 'Switch to your own GitHub App'."
164
+ )
165
+ if err:
166
+ description = payload.get("error_description", err)
167
+ raise AuthError(f"GitHub returned an error during polling ({err}): {description}.")
168
+
169
+ raise AuthError("Token response missing both access_token and error.")
170
+
171
+
172
+ def run_login(
173
+ *,
174
+ stdin=None,
175
+ stdout=None,
176
+ stderr=None,
177
+ sleep=time.sleep,
178
+ now=time.monotonic,
179
+ ) -> int:
180
+ stdin = stdin or sys.stdin
181
+ stdout = stdout or sys.stdout
182
+ stderr = stderr or sys.stderr
183
+ try:
184
+ host = _prompt_host(stdin=stdin, stderr=stderr)
185
+ device = _request_device_code(host)
186
+ except AuthError as exc:
187
+ print(f"error: {exc}", file=stderr)
188
+ return 2
189
+
190
+ print(
191
+ f"Visit {device['verification_uri']} and enter the code: {device['user_code']}",
192
+ file=stdout,
193
+ )
194
+ print("Waiting for authorization...", file=stderr)
195
+
196
+ try:
197
+ token = _poll_for_token(
198
+ host,
199
+ device["device_code"],
200
+ int(device.get("interval", 5)),
201
+ now=now,
202
+ sleep=sleep,
203
+ )
204
+ except KeyboardInterrupt:
205
+ print("Login cancelled.", file=stderr)
206
+ return 2
207
+ except AuthError as exc:
208
+ print(f"error: {exc}", file=stderr)
209
+ return 2
210
+
211
+ if not token.startswith("ghu_"):
212
+ print(
213
+ "error: GitHub returned a non-GitHub-App token (expected `ghu_…` prefix). "
214
+ "The bundled CLIENT_ID is misconfigured — see README 'Switch to your own GitHub App'.",
215
+ file=stderr,
216
+ )
217
+ return 2
218
+
219
+ auth = Auth(token=token, host=host, source="native")
220
+
221
+ try:
222
+ api.fetch_quota(auth)
223
+ except NoSubscriptionError:
224
+ print(
225
+ "error: post-login verification failed: this account has no Copilot quota.",
226
+ file=stderr,
227
+ )
228
+ return 2
229
+ except api.APIError as exc:
230
+ scrubbed = paths.scrub(str(exc), token)
231
+ print(f"error: post-login verification failed: {scrubbed}", file=stderr)
232
+ return 2
233
+
234
+ target = paths.auth_path()
235
+ if target.exists():
236
+ print(
237
+ "Re-authenticating — previous credentials will be replaced.",
238
+ file=stderr,
239
+ )
240
+
241
+ try:
242
+ paths.write_secret_file(
243
+ target,
244
+ {"github-copilot": {"token": token, "host": host}},
245
+ )
246
+ except AuthError as exc:
247
+ print(f"error: could not write credentials: {exc}", file=stderr)
248
+ return 2
249
+ except OSError as exc:
250
+ print(f"error: could not write credentials: {exc}", file=stderr)
251
+ return 2
252
+
253
+ # Clean up any stale session.json from versions that cached a session token.
254
+ paths.delete_secret_file(paths.config_dir() / "session.json")
255
+
256
+ print(f"Logged in to GitHub Copilot via {host}.", file=stdout)
257
+ return 0
258
+
259
+
260
+ def run_logout(*, stdout=None) -> int:
261
+ stdout = stdout or sys.stdout
262
+ paths.delete_secret_file(paths.auth_path())
263
+ # Legacy cleanup: pre-session-removal versions cached a session token here.
264
+ paths.delete_secret_file(paths.config_dir() / "session.json")
265
+ print("Logged out of copilot-spend.", file=stdout)
266
+ return 0
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from copilot_spend.quota import Spend
6
+
7
+
8
+ def _format_dollars(amount: float) -> str:
9
+ if amount < 0:
10
+ return f"-${abs(amount):.2f}"
11
+ return f"${amount:.2f}"
12
+
13
+
14
+ def _format_reset(reset: datetime | None, now: datetime) -> str:
15
+ if reset is None:
16
+ return "next reset: unknown"
17
+
18
+ absolute = reset.strftime("%b %d, %Y")
19
+ delta_days = (reset.date() - now.date()).days
20
+
21
+ if delta_days == 0:
22
+ relative = "today"
23
+ elif delta_days == 1:
24
+ relative = "tomorrow"
25
+ elif delta_days > 1:
26
+ relative = f"in {delta_days} days"
27
+ elif delta_days == -1:
28
+ relative = "yesterday (overdue)"
29
+ else:
30
+ relative = f"overdue by {abs(delta_days)} days"
31
+
32
+ return f"{absolute} ({relative})"
33
+
34
+
35
+ def render(spend: Spend, *, now: datetime) -> str:
36
+ plan_label = f" ({spend.plan})" if spend.plan else ""
37
+ login_label = spend.login or "<unknown account>"
38
+
39
+ lines = [
40
+ f"GitHub Copilot - {login_label}{plan_label}",
41
+ f" Used: {spend.consumed} PRUs",
42
+ f" Allowance: {_format_dollars(spend.dollars_entitlement)} ({spend.entitlement} PRUs included)",
43
+ ]
44
+
45
+ if spend.billable_prus > 0:
46
+ lines.append(
47
+ f" Billable: {_format_dollars(spend.dollars_owed)}"
48
+ f" ({spend.billable_prus} PRUs over allowance at $0.04/PRU)"
49
+ )
50
+ else:
51
+ lines.append(
52
+ f" Remaining: {_format_dollars(spend.dollars_free_remaining)}"
53
+ f" ({spend.free_remaining_prus} PRUs of free allowance left)"
54
+ )
55
+
56
+ lines.append(f" Resets: {_format_reset(spend.reset, now)}")
57
+ return "\n".join(lines)
copilot_spend/paths.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .auth import AuthError
11
+
12
+
13
+ def config_dir() -> Path:
14
+ override = os.environ.get("COPILOT_SPEND_CONFIG_DIR")
15
+ if override:
16
+ return Path(override)
17
+ xdg = os.environ.get("XDG_CONFIG_HOME")
18
+ if xdg:
19
+ return Path(xdg) / "copilot-spend"
20
+ return Path.home() / ".config" / "copilot-spend"
21
+
22
+
23
+ def auth_path() -> Path:
24
+ return config_dir() / "auth.json"
25
+
26
+
27
+ def assert_safe_parent(parent: Path) -> None:
28
+ if os.name != "posix":
29
+ return
30
+ if not parent.exists():
31
+ return
32
+ try:
33
+ info = parent.stat()
34
+ except OSError as exc:
35
+ raise AuthError(f"Cannot stat config directory {parent}: {exc}") from None
36
+ if info.st_uid != os.getuid():
37
+ raise AuthError(
38
+ f"Refusing to use config directory {parent}: owned by uid "
39
+ f"{info.st_uid}, expected {os.getuid()}. "
40
+ "Set COPILOT_SPEND_CONFIG_DIR or XDG_CONFIG_HOME to a directory "
41
+ "you own."
42
+ )
43
+ if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
44
+ raise AuthError(
45
+ f"Refusing to use config directory {parent}: mode "
46
+ f"{oct(stat.S_IMODE(info.st_mode))} grants group or world write. "
47
+ f"Run `chmod 700 {parent}`."
48
+ )
49
+
50
+
51
+ def write_secret_file(path: Path, payload: dict[str, Any]) -> None:
52
+ parent = path.parent
53
+ assert_safe_parent(parent)
54
+ parent.mkdir(parents=True, exist_ok=True)
55
+ if os.name == "posix":
56
+ os.chmod(parent, 0o700)
57
+
58
+ fd, tmp_str = tempfile.mkstemp(dir=parent, prefix=".tmp-", suffix=".json")
59
+ tmp_path = Path(tmp_str)
60
+ try:
61
+ if os.name == "posix":
62
+ os.chmod(fd, 0o600)
63
+ with os.fdopen(fd, "wb") as handle:
64
+ handle.write(json.dumps(payload, indent=2).encode("utf-8"))
65
+ handle.flush()
66
+ os.fsync(handle.fileno())
67
+ os.replace(tmp_path, path)
68
+ except BaseException:
69
+ tmp_path.unlink(missing_ok=True)
70
+ raise
71
+
72
+
73
+ def delete_secret_file(path: Path) -> None:
74
+ path.unlink(missing_ok=True)
75
+
76
+
77
+ def scrub(text: str, *tokens: str | None) -> str:
78
+ for token in tokens:
79
+ if token and token in text:
80
+ text = text.replace(token, "<redacted-token>")
81
+ return text
copilot_spend/quota.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ # Published GitHub Copilot premium request unit price as of 2026-05.
8
+ # Update this constant if GitHub changes the rate.
9
+ PRU_PRICE_USD = 0.04
10
+
11
+ # Plausible field names for the next-reset timestamp. Searched at the payload
12
+ # top level first, then inside `quota_snapshots.premium_interactions`. The
13
+ # endpoint is undocumented; live observation (2026-05) shows `quota_reset_date`
14
+ # at the top level with a date-only string. The other names are defensive
15
+ # fallbacks in case GitHub renames or relocates the field.
16
+ RESET_FIELD_CANDIDATES: tuple[str, ...] = (
17
+ "quota_reset_date",
18
+ "next_reset",
19
+ "reset_date",
20
+ "resets_at",
21
+ "reset",
22
+ "next_reset_date",
23
+ )
24
+
25
+ # Nested paths inside `premium_interactions` to search when no flat candidate
26
+ # matches. Each path is a tuple of keys to walk.
27
+ RESET_NESTED_PATHS: tuple[tuple[str, ...], ...] = (
28
+ ("reset", "date"),
29
+ ("reset", "at"),
30
+ ("next", "reset"),
31
+ )
32
+
33
+
34
+ class NoSubscriptionError(Exception):
35
+ pass
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Spend:
40
+ login: str
41
+ plan: str
42
+ entitlement: int # included free PRUs per period
43
+ consumed: int # total PRUs used this period (>= 0)
44
+ billable_prus: int # max(0, consumed - entitlement)
45
+ free_remaining_prus: int # max(0, entitlement - consumed)
46
+ dollars_owed: float # billable_prus * PRU_PRICE_USD
47
+ dollars_entitlement: float # entitlement * PRU_PRICE_USD (reference)
48
+ dollars_free_remaining: float # free_remaining_prus * PRU_PRICE_USD
49
+ reset: datetime | None
50
+
51
+
52
+ def _parse_iso(value: Any) -> datetime | None:
53
+ if not isinstance(value, str) or not value:
54
+ return None
55
+ try:
56
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
57
+ except ValueError:
58
+ return None
59
+ # Date-only strings (e.g. "2026-06-01") parse to naive datetimes; attach
60
+ # UTC so downstream comparisons and formatting are consistent.
61
+ if parsed.tzinfo is None:
62
+ parsed = parsed.replace(tzinfo=timezone.utc)
63
+ return parsed
64
+
65
+
66
+ def _extract_reset(payload: dict, pi: dict) -> datetime | None:
67
+ for source in (payload, pi):
68
+ for name in RESET_FIELD_CANDIDATES:
69
+ if name in source:
70
+ parsed = _parse_iso(source[name])
71
+ if parsed is not None:
72
+ return parsed
73
+ for path in RESET_NESTED_PATHS:
74
+ cursor: Any = pi
75
+ for key in path:
76
+ if isinstance(cursor, dict) and key in cursor:
77
+ cursor = cursor[key]
78
+ else:
79
+ cursor = None
80
+ break
81
+ parsed = _parse_iso(cursor)
82
+ if parsed is not None:
83
+ return parsed
84
+ return None
85
+
86
+
87
+ def parse_quota(payload: dict) -> Spend:
88
+ plan = payload.get("copilot_plan") or ""
89
+ snapshots = payload.get("quota_snapshots") or {}
90
+ pi = snapshots.get("premium_interactions") if isinstance(snapshots, dict) else None
91
+
92
+ # Origin R7: missing copilot_plan OR missing premium_interactions → no subscription.
93
+ if not plan or not isinstance(pi, dict):
94
+ raise NoSubscriptionError("No Copilot quota on this account.")
95
+
96
+ try:
97
+ entitlement = int(pi["entitlement"])
98
+ remaining = int(pi["remaining"])
99
+ except (KeyError, TypeError, ValueError) as exc:
100
+ raise NoSubscriptionError(
101
+ f"premium_interactions missing entitlement/remaining: {exc}"
102
+ ) from None
103
+
104
+ # API semantics observed against a business-plan account:
105
+ # `remaining` counts down from 0 as you consume PRUs (so the value is
106
+ # ≤ 0 in steady state on this plan class). `entitlement` is the free
107
+ # credit per period — the first N PRUs are not billable.
108
+ # Defensive: if `remaining` is positive (unobserved case, possibly other
109
+ # plan classes), treat it as zero consumption rather than guessing.
110
+ consumed = max(0, -remaining)
111
+ billable_prus = max(0, consumed - entitlement)
112
+ free_remaining_prus = max(0, entitlement - consumed)
113
+
114
+ dollars_owed = round(billable_prus * PRU_PRICE_USD, 2)
115
+ dollars_entitlement = round(entitlement * PRU_PRICE_USD, 2)
116
+ dollars_free_remaining = round(free_remaining_prus * PRU_PRICE_USD, 2)
117
+
118
+ reset = _extract_reset(payload, pi)
119
+
120
+ login = payload.get("login") or ""
121
+ if not isinstance(login, str):
122
+ login = ""
123
+ if not isinstance(plan, str):
124
+ plan = ""
125
+
126
+ return Spend(
127
+ login=login,
128
+ plan=plan,
129
+ entitlement=entitlement,
130
+ consumed=consumed,
131
+ billable_prus=billable_prus,
132
+ free_remaining_prus=free_remaining_prus,
133
+ dollars_owed=dollars_owed,
134
+ dollars_entitlement=dollars_entitlement,
135
+ dollars_free_remaining=dollars_free_remaining,
136
+ reset=reset,
137
+ )
138
+
139
+
140
+ __all__ = [
141
+ "PRU_PRICE_USD",
142
+ "RESET_FIELD_CANDIDATES",
143
+ "RESET_NESTED_PATHS",
144
+ "NoSubscriptionError",
145
+ "Spend",
146
+ "parse_quota",
147
+ ]
@@ -0,0 +1,263 @@
1
+ Metadata-Version: 2.4
2
+ Name: copilot-spend
3
+ Version: 0.1.0
4
+ Summary: Print your current-period GitHub Copilot spend and reset date.
5
+ Project-URL: Homepage, https://github.com/nkootstra/copilot-spend
6
+ Project-URL: Source, https://github.com/nkootstra/copilot-spend
7
+ Project-URL: Issues, https://github.com/nkootstra/copilot-spend/issues
8
+ Project-URL: Changelog, https://github.com/nkootstra/copilot-spend/blob/main/CHANGELOG.md
9
+ Project-URL: Documentation, https://github.com/nkootstra/copilot-spend#readme
10
+ Author-email: Niels Kootstra <niels.kootstra@gmail.com>
11
+ License: MIT License
12
+
13
+ Copyright (c) 2026 Niels
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE
33
+ Keywords: cli,copilot,github,quota,spend
34
+ Classifier: Development Status :: 3 - Alpha
35
+ Classifier: Environment :: Console
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Operating System :: MacOS
39
+ Classifier: Operating System :: POSIX :: Linux
40
+ Classifier: Programming Language :: Python :: 3
41
+ Classifier: Programming Language :: Python :: 3.10
42
+ Classifier: Programming Language :: Python :: 3.11
43
+ Classifier: Programming Language :: Python :: 3.12
44
+ Classifier: Programming Language :: Python :: 3.13
45
+ Classifier: Topic :: Software Development
46
+ Classifier: Topic :: Utilities
47
+ Requires-Python: >=3.10
48
+ Provides-Extra: dev
49
+ Requires-Dist: pytest>=7; extra == 'dev'
50
+ Description-Content-Type: text/markdown
51
+
52
+ # copilot-spend
53
+
54
+ Find out what your Copilot habit actually costs.
55
+
56
+ A small Python CLI that reads your GitHub Copilot quota and prints
57
+ your current-period spend in dollars and PRUs, plus when the period resets.
58
+ Works against both `github.com` and GitHub Enterprise hosts.
59
+
60
+ ## How it works
61
+
62
+ ```mermaid
63
+ flowchart TD
64
+ Start([copilot-spend ...]) --> Cmd{subcommand?}
65
+
66
+ Cmd -- login --> Host[prompt: github.com or GHE host]
67
+ Host --> DeviceCode[POST /login/device/code]
68
+ DeviceCode --> ShowCode[show user code + verification URL]
69
+ ShowCode --> Poll[poll /login/oauth/access_token]
70
+ Poll -- access_denied / expired --> LoginFail[/exit 2: login failed/]
71
+ Poll -- ghu_ token --> Verify[verify token works<br/>GET /copilot_internal/user]
72
+ Verify -- fail --> LoginFail
73
+ Verify -- ok --> WriteAuth[write auth.json 0o600]
74
+ WriteAuth --> LoginDone[/exit 0/]
75
+
76
+ Cmd -- logout --> DeleteFiles[delete auth.json]
77
+ DeleteFiles --> LogoutDone[/exit 0/]
78
+
79
+ Cmd -- bare --> Native{native<br/>~/.config/copilot-spend/auth.json?}
80
+ Native -- yes --> NativeBearer[bearer = ghu_ token]
81
+ Native -- no --> Opencode{opencode<br/>~/.local/share/opencode/auth.json?}
82
+ Opencode -- yes --> OpencodeBearer[bearer = gho_ token]
83
+ Opencode -- no --> NoCreds[/exit 2: run copilot-spend login/]
84
+ NativeBearer --> Quota[GET /copilot_internal/user with Bearer]
85
+ OpencodeBearer --> Quota
86
+ Quota -- 200 --> Print[/print spend + reset date<br/>exit 0/]
87
+ Quota -- 401 / 403 --> AuthErr[/exit 2: re-authenticate/]
88
+ Quota -- 404 --> NoSub[/exit 4: no Copilot quota/]
89
+ Quota -- 5xx / timeout --> ApiErr[/exit 3: API error/]
90
+ ```
91
+
92
+ The bare `copilot-spend` invocation, expanded as numbered steps:
93
+
94
+ 1. Resolves a GitHub Copilot token from the first source that exists, in
95
+ this order:
96
+ 1. Native: `~/.config/copilot-spend/auth.json`, created by running
97
+ `copilot-spend login`.
98
+ 2. Opencode fallback: `~/.local/share/opencode/auth.json` (keys
99
+ `github-copilot.access` and `github-copilot.enterpriseUrl`), if
100
+ opencode is installed.
101
+ 2. Uses the resolved token directly as the `Bearer` for the next step.
102
+ Both source kinds work the same way: native gives a `ghu_…` GitHub App
103
+ user-to-server token, opencode gives a `gho_…` OAuth App token, and
104
+ `/copilot_internal/user` accepts either one directly.
105
+ 3. Calls `GET /api/v3/copilot_internal/user` on your GHE host, or
106
+ `GET https://api.github.com/copilot_internal/user` if no enterprise
107
+ host is configured.
108
+ 4. Computes the billable overage:
109
+ `billable_PRUs = max(0, consumed - entitlement)`, then
110
+ `dollars_owed = billable_PRUs × $0.04`.
111
+ The first `entitlement` PRUs each period are included with your plan
112
+ and cost nothing.
113
+ 5. Prints a plain-text summary on stdout.
114
+
115
+ No background daemon. No history. No session-token cache. One HTTP
116
+ request per run.
117
+
118
+ ## Requirements
119
+
120
+ - Python 3.10 or newer
121
+ - macOS or Linux
122
+ - A GitHub Copilot token, obtained by either:
123
+ - running `copilot-spend login`, or
124
+ - having opencode installed and authenticated already
125
+
126
+ ## Install
127
+
128
+ ```sh
129
+ # Option A: pipx (persistent, isolated)
130
+ pipx install copilot-spend
131
+
132
+ # Option B: pip (into your active environment or --user)
133
+ pip install copilot-spend
134
+
135
+ # Option C: uv tool (persistent, isolated)
136
+ uv tool install copilot-spend
137
+
138
+ # Option D: one-off run, no install
139
+ uvx copilot-spend
140
+ pipx run copilot-spend
141
+ ```
142
+
143
+ ### Install from source
144
+
145
+ ```sh
146
+ git clone https://github.com/nkootstra/copilot-spend.git
147
+ cd copilot-spend
148
+
149
+ pipx install .
150
+ # or:
151
+ uv tool install --from . copilot-spend
152
+ # or one-off:
153
+ uvx --from . copilot-spend
154
+ ```
155
+
156
+ ## Usage
157
+
158
+ ```sh
159
+ copilot-spend # print current-period quota
160
+ copilot-spend login # authenticate via GitHub OAuth device flow
161
+ copilot-spend logout # remove copilot-spend's stored credentials
162
+ ```
163
+
164
+ `copilot-spend login` prompts for github.com or a GHE host, shows a
165
+ device code with a URL to visit, then polls until you complete the
166
+ flow in your browser. The new token is verified against
167
+ `/copilot_internal/user` before anything gets persisted. Credentials
168
+ land in `~/.config/copilot-spend/auth.json` (mode `0o600`). If you
169
+ already have opencode authenticated, the bare `copilot-spend` command
170
+ continues to work without login.
171
+
172
+ Example output (under your allowance):
173
+
174
+ ```
175
+ GitHub Copilot - your-login (business)
176
+ Used: 221 PRUs
177
+ Allowance: $12.00 (300 PRUs included)
178
+ Remaining: $3.16 (79 PRUs of free allowance left)
179
+ Resets: May 31, 2026 (in 15 days)
180
+ ```
181
+
182
+ Example output (over your allowance — billable overage):
183
+
184
+ ```
185
+ GitHub Copilot - your-login (business)
186
+ Used: 4073 PRUs
187
+ Allowance: $12.00 (300 PRUs included)
188
+ Billable: $150.92 (3773 PRUs over allowance at $0.04/PRU)
189
+ Resets: Jun 01, 2026 (in 16 days)
190
+ ```
191
+
192
+ Flags: `--help`, `--version`. Subcommands: `login`, `logout`.
193
+
194
+ ## Exit codes
195
+
196
+ | Code | Meaning |
197
+ |------|---------|
198
+ | 0 | Success |
199
+ | 1 | Unexpected error |
200
+ | 2 | Auth error (missing/invalid `auth.json` or token) |
201
+ | 3 | API error (network, timeout, 4xx/5xx) |
202
+ | 4 | No Copilot quota on the account |
203
+
204
+ ## Switch to your own GitHub App
205
+
206
+ `copilot-spend login` runs the GitHub OAuth device flow against
207
+ Microsoft's well-known VS Code Copilot GitHub App
208
+ (`Iv1.b507a08c87ecfe98`). This is the same client ID used by every
209
+ working third-party Copilot tool — copilot.vim, avante.nvim, LiteLLM,
210
+ and others — because GitHub's session-token exchange endpoint
211
+ (`/copilot_internal/v2/token`) only accepts tokens issued by a GitHub
212
+ App, not by an OAuth App.
213
+
214
+ The trade-off: the GitHub consent screen during login says "GitHub for
215
+ VS Code" rather than "copilot-spend", and you depend on Microsoft not
216
+ rotating that app. To remove both, register your own GitHub App and
217
+ swap the constant:
218
+
219
+ 1. Visit https://github.com/settings/apps and click **New GitHub App**.
220
+ This must be a *GitHub App*, not an *OAuth App* — OAuth Apps issue
221
+ `gho_…` tokens that the Copilot exchange endpoint rejects with 404.
222
+ 2. Set Homepage URL and Callback URL to anything (the device flow does
223
+ not use them).
224
+ 3. Enable **Device flow** under "Identifying and authorizing users".
225
+ 4. Account permissions: none required beyond user identification.
226
+ The `read:user` OAuth scope is enough.
227
+ 5. Note the resulting **Client ID** (starts with `Iv23` or `Iv1.`).
228
+ 6. Replace the `CLIENT_ID` constant in
229
+ `src/copilot_spend/login.py` with your new client ID.
230
+ 7. Rebuild/reinstall (`pipx install --force .` or
231
+ `uv tool install --force --from . copilot-spend`).
232
+
233
+ After the swap, the consent screen shows your app's name and your
234
+ copilot-spend install no longer breaks if Microsoft rotates
235
+ `Iv1.b507a08c87ecfe98`.
236
+
237
+ ## Caveats
238
+
239
+ - The PRU price is hardcoded at $0.04 (correct as of 2026-05). Update the
240
+ constant in `src/copilot_spend/quota.py` if GitHub changes it.
241
+ - v1 ships with VS Code's GitHub App ID `Iv1.b507a08c87ecfe98` for the
242
+ device flow. See "Switch to your own GitHub App" above to remove the
243
+ dependency.
244
+ - The billing model assumed: the first `entitlement` PRUs each period are
245
+ included with your plan, and anything beyond that is billable at $0.04
246
+ per PRU. This matches observed behavior on a business plan. Org-level
247
+ caps or contracts may change what you actually pay — treat the
248
+ `Billable` figure as a personal estimate, not an invoice.
249
+ - The reset-date field name in the Copilot API response is best-effort:
250
+ `copilot-spend` tries the field names observed on a real response, plus
251
+ a few defensive fallbacks, and prints `next reset: unknown` if none
252
+ match. Adjust `RESET_FIELD_CANDIDATES` in `quota.py` if your response
253
+ uses a different name.
254
+ - The `/copilot_internal/user` endpoint is not a public, documented API.
255
+ GitHub may change its shape at any time.
256
+
257
+ ## Development
258
+
259
+ ```sh
260
+ python -m venv .venv
261
+ .venv/bin/pip install -e ".[dev]"
262
+ .venv/bin/pytest
263
+ ```
@@ -0,0 +1,14 @@
1
+ copilot_spend/__init__.py,sha256=mOUnkKLmpJwiljM2PF7UB0ifT-94wKv8Bo_NGBBIgt8,77
2
+ copilot_spend/__main__.py,sha256=aI19Q7QEFhIHNZatXr3Xkrwj1-p5nQ--LVEb48dIvzI,88
3
+ copilot_spend/api.py,sha256=TpaEISTOw5ZX4lZVJZgaf9l2-CuXTWQL8tVy73-7ilk,3587
4
+ copilot_spend/auth.py,sha256=cTz98IDi_1HnZxQt3e4EL0vjCx4FZjYeaK330zhju1s,5819
5
+ copilot_spend/cli.py,sha256=0tAkQJS43b1m2x0-3OHR68Qw0UEzLcaDtVxdDa4x9-M,2758
6
+ copilot_spend/login.py,sha256=cp7dxdP4dw0b4yP05rvPy0P2UUdz936gSa62E2jFMOg,8678
7
+ copilot_spend/output.py,sha256=jzj1TWxfjwcQkCPd-O5ULjVlZPFp9jdPjl4XFIs-SfY,1693
8
+ copilot_spend/paths.py,sha256=3SrN3QV-5ybCGh4oKvbR2mu5oS0MwN1yxdk-mgAAhhc,2340
9
+ copilot_spend/quota.py,sha256=tVnUdoPQRVa7D0-xmTB0qZI3k5kFNkfmHCeHv96UdDc,5023
10
+ copilot_spend-0.1.0.dist-info/METADATA,sha256=lgtmVNaC5xo5PFu-0GL8i0VGF2uvZzS8t6hThcqffPU,10371
11
+ copilot_spend-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ copilot_spend-0.1.0.dist-info/entry_points.txt,sha256=HGni_718xl1N0OzIN8Nq7KOJtghb1GDUXhgP8ENwwGM,64
13
+ copilot_spend-0.1.0.dist-info/licenses/LICENSE,sha256=OIDefE7LMks4ClJdo8FZ4rJNaaoNksNHKYDfpLjump8,1062
14
+ copilot_spend-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ copilot-spend = copilot_spend.cli:_entrypoint
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niels
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.