View your Github Copilot usage from the CLI
- Python3 - should work with just about any Python sunce 3.6
- gh cli installed and authed - we use the gh cli auth token to fetch the usage data
copy to somewhere in your $PATH and make it executable
| #!/usr/bin/env python3 | |
| """Show Copilot premium interaction quota usage.""" | |
| import argparse | |
| import json | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import time | |
| import uuid | |
| from datetime import datetime, timezone | |
| from typing import Any, Optional | |
| API_PATH = "/copilot_internal/user" | |
| # ── Helpers ── | |
| def gh_api_json(hostname: Optional[str], timeout: int) -> dict: | |
| cmd = ["gh", "api", API_PATH, "-H", "Accept: application/json"] | |
| if hostname: | |
| cmd += ["--hostname", hostname] | |
| try: | |
| p = subprocess.run( | |
| cmd, | |
| capture_output=True, | |
| text=True, | |
| timeout=timeout, | |
| ) | |
| except FileNotFoundError as e: | |
| raise RuntimeError("gh is not installed (or not on PATH).") from e | |
| except subprocess.TimeoutExpired as e: | |
| raise RuntimeError(f"Timed out after {timeout}s.") from e | |
| if p.returncode != 0: | |
| msg = (p.stderr or p.stdout or "").strip() | |
| raise RuntimeError(msg or f"`gh api` failed (exit {p.returncode}).") | |
| try: | |
| return json.loads(p.stdout) | |
| except json.JSONDecodeError as e: | |
| raise RuntimeError("Failed to parse JSON returned by `gh api`.") from e | |
| def _as_int_if_close(v: float): | |
| if abs(v - round(v)) < 1e-9: | |
| return int(round(v)) | |
| return v | |
| def parse_snapshot(snapshot: Any): | |
| """Returns {used, total, remaining}, 'unlimited', or None.""" | |
| if not isinstance(snapshot, dict) or not snapshot: | |
| return None | |
| if snapshot.get("unlimited"): | |
| return "unlimited" | |
| total = snapshot.get("entitlement") | |
| remaining = snapshot.get("remaining") | |
| if remaining is None: | |
| remaining = snapshot.get("quota_remaining") | |
| if total is None or remaining is None: | |
| return None | |
| try: | |
| total_f = float(total) | |
| remaining_f = float(remaining) | |
| except (TypeError, ValueError): | |
| return None | |
| used_f = max(total_f - remaining_f, 0.0) | |
| return { | |
| "used": _as_int_if_close(used_f), | |
| "total": _as_int_if_close(total_f), | |
| "remaining": _as_int_if_close(remaining_f), | |
| } | |
| def snapshot_metrics(parsed) -> dict: | |
| if parsed == "unlimited": | |
| return {"status": "unlimited"} | |
| if not parsed: | |
| return {"status": "unknown"} | |
| used, total, remaining = parsed["used"], parsed["total"], parsed["remaining"] | |
| try: | |
| pct = int(100 * float(used) / float(total)) if float(total) else 0 | |
| except (TypeError, ValueError, ZeroDivisionError): | |
| pct = 0 | |
| return {"used": used, "total": total, "remaining": remaining, "pct": pct} | |
| def format_metrics(metrics: dict) -> str: | |
| status = metrics.get("status") | |
| if status == "unlimited": | |
| return "unlimited" | |
| if status == "unknown": | |
| return "unknown" | |
| return f"{metrics['used']}/{metrics['total']} - {metrics['pct']}%" | |
| def format_reset_date(data: dict) -> Optional[str]: | |
| s = data.get("quota_reset_date") | |
| if isinstance(s, str) and s: | |
| try: | |
| return datetime.strptime(s, "%Y-%m-%d").strftime("%d/%m/%y") | |
| except ValueError: | |
| pass | |
| s = data.get("quota_reset_date_utc") | |
| if isinstance(s, str) and s: | |
| try: | |
| return datetime.fromisoformat(s.replace("Z", "+00:00")).strftime("%d/%m/%y") | |
| except ValueError: | |
| pass | |
| return None | |
| def collect_snapshots(data: dict) -> dict: | |
| raw = data.get("quota_snapshots") | |
| if not isinstance(raw, dict): | |
| return {} | |
| return {k: snapshot_metrics(parse_snapshot(v)) for k, v in raw.items()} | |
| def snap(snapshots: dict, key: str) -> dict: | |
| return snapshots.get(key) or {"status": "unknown"} | |
| def get_premium_used(snapshots: dict) -> Optional[float]: | |
| s = snap(snapshots, "premium_interactions") | |
| used = s.get("used") | |
| return float(used) if used is not None else None | |
| def build_watch_record( | |
| snapshots: dict, | |
| reset: Optional[str], | |
| *, | |
| detailed: bool, | |
| all_: bool, | |
| ) -> dict: | |
| record: dict = { | |
| "timestamp": datetime.now(timezone.utc).isoformat(), | |
| "reset_date": reset, | |
| } | |
| if all_: | |
| record["snapshots"] = dict(sorted(snapshots.items())) | |
| elif detailed: | |
| record["premium"] = snap(snapshots, "premium_interactions") | |
| record["chat"] = snap(snapshots, "chat") | |
| record["completions"] = snap(snapshots, "completions") | |
| else: | |
| record["premium"] = snap(snapshots, "premium_interactions") | |
| return record | |
| # ── Keyboard input (cross-platform) ── | |
| def _can_read_keys() -> bool: | |
| """Return True if we can do non-blocking single-key reads from stdin.""" | |
| if not sys.stdin.isatty(): | |
| return False | |
| if sys.platform == "win32": | |
| try: | |
| import msvcrt # noqa: F401 | |
| return True | |
| except ImportError: | |
| return False | |
| else: | |
| try: | |
| import select # noqa: F401 | |
| import termios # noqa: F401 | |
| import tty # noqa: F401 | |
| return True | |
| except ImportError: | |
| return False | |
| def _check_for_quit() -> bool: | |
| """Non-blocking check: return True if 'q' was pressed.""" | |
| if sys.platform == "win32": | |
| import msvcrt | |
| if msvcrt.kbhit(): | |
| ch = msvcrt.getwch() | |
| if ch.lower() == "q": | |
| return True | |
| else: | |
| import select | |
| import termios | |
| import tty | |
| old = termios.tcgetattr(sys.stdin) | |
| try: | |
| tty.setcbreak(sys.stdin.fileno()) | |
| rlist, _, _ = select.select([sys.stdin], [], [], 0) | |
| if rlist: | |
| ch = sys.stdin.read(1) | |
| if ch.lower() == "q": | |
| return True | |
| finally: | |
| termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old) | |
| return False | |
| def _terminal_width() -> int: | |
| return shutil.get_terminal_size((80, 24)).columns | |
| # ── Watch mode ── | |
| def watch_loop(args) -> int: | |
| log_file = args.log_file | |
| if not log_file: | |
| sid = uuid.uuid4().hex[-5:] | |
| log_file = f"ghcp-usage-{sid}.jsonl" | |
| log_path = os.path.abspath(log_file) | |
| can_keys = _can_read_keys() | |
| quit_hint = " Press 'q' to stop." if can_keys else " Send SIGINT to stop." | |
| print( | |
| f"Monitoring Copilot usage every {args.interval} seconds.{quit_hint}", | |
| file=sys.stderr, | |
| ) | |
| print(f"Logging JSONL to: {log_path}", file=sys.stderr) | |
| print("", file=sys.stderr) | |
| session_start = datetime.now() | |
| start_used: Optional[float] = None | |
| latest_used: Optional[float] = None | |
| poll_count = 0 | |
| quit_requested = False | |
| width = _terminal_width() | |
| try: | |
| while not quit_requested: | |
| poll_count += 1 | |
| # ── Poll ── | |
| data = None | |
| try: | |
| data = gh_api_json(args.hostname, args.timeout) | |
| except Exception as exc: | |
| err_record = { | |
| "timestamp": datetime.now(timezone.utc).isoformat(), | |
| "error": str(exc), | |
| } | |
| with open(log_path, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(err_record, sort_keys=True) + "\n") | |
| line = f"[{datetime.now():%H:%M:%S}] Error: {exc}" | |
| sys.stderr.write(f"\r{line:<{width - 1}}") | |
| sys.stderr.flush() | |
| if data is not None: | |
| reset = format_reset_date(data) | |
| snapshots = collect_snapshots(data) | |
| record = build_watch_record( | |
| snapshots, reset, detailed=args.detailed, all_=args.all | |
| ) | |
| with open(log_path, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(record, sort_keys=True) + "\n") | |
| current_used = get_premium_used(snapshots) | |
| if current_used is not None: | |
| if start_used is None: | |
| start_used = current_used | |
| latest_used = current_used | |
| premium_display = format_metrics( | |
| snap(snapshots, "premium_interactions") | |
| ) | |
| delta = "" | |
| if start_used is not None and latest_used is not None: | |
| d = max(latest_used - start_used, 0) | |
| d = _as_int_if_close(d) | |
| delta = f" | session: +{d}" | |
| line = f"[{datetime.now():%H:%M:%S}] {premium_display}{delta}" | |
| sys.stderr.write(f"\r{line:<{width - 1}}") | |
| sys.stderr.flush() | |
| # ── Interruptible sleep ── | |
| deadline = time.monotonic() + args.interval | |
| while time.monotonic() < deadline: | |
| if can_keys: | |
| try: | |
| if _check_for_quit(): | |
| quit_requested = True | |
| break | |
| except Exception: | |
| pass | |
| remaining = deadline - time.monotonic() | |
| time.sleep(min(0.25, max(0.001, remaining))) | |
| except KeyboardInterrupt: | |
| pass | |
| # ── Session summary ── | |
| session_end = datetime.now() | |
| duration = session_end - session_start | |
| total_secs = int(duration.total_seconds()) | |
| hours, rem = divmod(total_secs, 3600) | |
| mins, secs = divmod(rem, 60) | |
| print("", file=sys.stderr) | |
| print("", file=sys.stderr) | |
| print("\033[36m── Session Summary ──\033[0m", file=sys.stderr) | |
| print(f" Start: {session_start:%Y-%m-%d %H:%M:%S}", file=sys.stderr) | |
| print(f" End: {session_end:%Y-%m-%d %H:%M:%S}", file=sys.stderr) | |
| print(f" Duration: {hours}h {mins}m {secs}s", file=sys.stderr) | |
| print(f" Polls: {poll_count}", file=sys.stderr) | |
| if start_used is not None and latest_used is not None: | |
| session_delta = _as_int_if_close(max(latest_used - start_used, 0)) | |
| print( | |
| f" Premium requests used this session: {session_delta} ({_as_int_if_close(start_used)} -> {_as_int_if_close(latest_used)})", | |
| file=sys.stderr, | |
| ) | |
| else: | |
| print( | |
| " Premium requests used this session: unknown (could not read usage)", | |
| file=sys.stderr, | |
| ) | |
| print(f" Log file: {log_path}", file=sys.stderr) | |
| return 0 | |
| # ── One-shot mode ── | |
| def one_shot(args) -> int: | |
| try: | |
| data = gh_api_json(args.hostname, args.timeout) | |
| except Exception as e: | |
| print(f"Error: {e}", file=sys.stderr) | |
| return 1 | |
| reset = format_reset_date(data) | |
| reset_suffix = f" - {reset}" if reset else "" | |
| snapshots = collect_snapshots(data) | |
| selected_mode = "default" | |
| if args.all: | |
| selected_mode = "all" | |
| elif args.detailed: | |
| selected_mode = "detailed" | |
| if args.json: | |
| if selected_mode == "default": | |
| payload = { | |
| "mode": "default", | |
| "reset_date": reset, | |
| "premium": snap(snapshots, "premium_interactions"), | |
| } | |
| elif selected_mode == "detailed": | |
| payload = { | |
| "mode": "detailed", | |
| "reset_date": reset, | |
| "premium": snap(snapshots, "premium_interactions"), | |
| "chat": snap(snapshots, "chat"), | |
| "completions": snap(snapshots, "completions"), | |
| } | |
| else: | |
| payload = { | |
| "mode": "all", | |
| "reset_date": reset, | |
| "snapshots": dict(sorted(snapshots.items())), | |
| } | |
| print(json.dumps(payload, indent=2, sort_keys=True)) | |
| return 0 | |
| if args.detailed: | |
| print(f"premium: {format_metrics(snap(snapshots, 'premium_interactions'))}") | |
| print(f"chat: {format_metrics(snap(snapshots, 'chat'))}") | |
| print(f"completions: {format_metrics(snap(snapshots, 'completions'))}") | |
| print(f"reset date: {reset or 'unknown'}") | |
| return 0 | |
| if args.all: | |
| for k in sorted(snapshots.keys()): | |
| print(f"{k}: {format_metrics(snapshots[k])}") | |
| print(f"reset date: {reset or 'unknown'}") | |
| return 0 | |
| premium_value = format_metrics(snap(snapshots, "premium_interactions")) | |
| print(f"{premium_value}{reset_suffix}") | |
| return 0 | |
| # ── CLI ── | |
| def main() -> int: | |
| ap = argparse.ArgumentParser( | |
| prog="ghcp-usage", | |
| description="Show Copilot premium interaction quota usage.", | |
| ) | |
| ap.add_argument( | |
| "--json", | |
| action="store_true", | |
| help="Print JSON for the selected output mode.", | |
| ) | |
| mode = ap.add_mutually_exclusive_group() | |
| mode.add_argument( | |
| "--detailed", | |
| action="store_true", | |
| help="Show premium/chat/completions plus reset date.", | |
| ) | |
| mode.add_argument( | |
| "--all", | |
| action="store_true", | |
| help="Show all quota snapshot values plus reset date.", | |
| ) | |
| ap.add_argument( | |
| "--watch", | |
| action="store_true", | |
| help="Continuously poll and log usage as JSONL. Press 'q' to stop.", | |
| ) | |
| ap.add_argument( | |
| "--interval", | |
| type=int, | |
| default=300, | |
| help="Polling interval in seconds for --watch mode (default: 300).", | |
| ) | |
| ap.add_argument( | |
| "--log-file", | |
| help="Path to the JSONL log file for --watch mode (default: ghcp-usage-<id>.jsonl).", | |
| ) | |
| ap.add_argument( | |
| "--hostname", | |
| help="GitHub hostname (for GHES); defaults to github.com via gh config.", | |
| ) | |
| ap.add_argument( | |
| "--timeout", | |
| type=int, | |
| default=15, | |
| help="Timeout seconds per API call (default: 15).", | |
| ) | |
| args = ap.parse_args() | |
| if args.interval < 1: | |
| ap.error("--interval must be at least 1 second.") | |
| if args.timeout < 1: | |
| ap.error("--timeout must be at least 1 second.") | |
| if args.watch: | |
| return watch_loop(args) | |
| return one_shot(args) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
| <# | |
| .SYNOPSIS | |
| Show Copilot premium interaction quota usage. | |
| .DESCRIPTION | |
| Queries the GitHub Copilot API via `gh` and displays quota usage information. | |
| With -Watch, polls at an interval and logs JSONL to a file. Press 'q' to stop | |
| and print a session summary showing requests used during the monitoring period. | |
| .PARAMETER Json | |
| Print JSON for the selected output mode. | |
| .PARAMETER Detailed | |
| Show premium/chat/completions plus reset date. | |
| .PARAMETER All | |
| Show all quota snapshot values plus reset date. | |
| .PARAMETER Watch | |
| Continuously poll and log usage as JSONL. Press 'q' to stop gracefully. | |
| .PARAMETER Interval | |
| Polling interval in seconds for -Watch mode (default: 300 = 5 minutes). | |
| .PARAMETER LogFile | |
| Path to the JSONL log file for -Watch mode (default: ghcp-usage-<id>.jsonl). | |
| .PARAMETER Hostname | |
| GitHub hostname (for GHES); defaults to github.com via gh config. | |
| .PARAMETER Timeout | |
| Timeout seconds per API call (default: 15). | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [switch]$Json, | |
| [switch]$Detailed, | |
| [switch]$All, | |
| [switch]$Watch, | |
| [ValidateRange(1, [int]::MaxValue)] | |
| [int]$Interval = 300, | |
| [string]$LogFile, | |
| [string]$Hostname, | |
| [ValidateRange(1, [int]::MaxValue)] | |
| [int]$Timeout = 15, | |
| [Alias('help')] | |
| [switch]$H | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| if ($H) { | |
| Get-Help $PSCommandPath -Detailed | |
| exit 0 | |
| } | |
| if ($Detailed -and $All) { | |
| [Console]::Error.WriteLine('-Detailed and -All cannot be used together.') | |
| exit 1 | |
| } | |
| $ApiPath = '/copilot_internal/user' | |
| # ── Helpers ── | |
| function Invoke-GhApiJson { | |
| param( | |
| [string]$ApiPath, | |
| [string]$Hostname, | |
| [int]$Timeout | |
| ) | |
| $ghExe = Get-Command 'gh' -CommandType Application -ErrorAction SilentlyContinue | |
| if (-not $ghExe) { | |
| throw 'gh is not installed (or not on PATH).' | |
| } | |
| $args_ = @('api', $ApiPath, '-H', 'Accept: application/json') | |
| if ($Hostname) { | |
| $args_ += '--hostname' | |
| $args_ += $Hostname | |
| } | |
| $pinfo = [System.Diagnostics.ProcessStartInfo]::new() | |
| $pinfo.FileName = $ghExe.Source | |
| $pinfo.Arguments = ($args_ | ForEach-Object { | |
| if ($_ -match '\s') { "`"$_`"" } else { $_ } | |
| }) -join ' ' | |
| $pinfo.RedirectStandardOutput = $true | |
| $pinfo.RedirectStandardError = $true | |
| $pinfo.UseShellExecute = $false | |
| $pinfo.CreateNoWindow = $true | |
| $proc = [System.Diagnostics.Process]::Start($pinfo) | |
| $stdoutTask = $proc.StandardOutput.ReadToEndAsync() | |
| $stderrTask = $proc.StandardError.ReadToEndAsync() | |
| if (-not $proc.WaitForExit($Timeout * 1000)) { | |
| try { $proc.Kill() } catch {} | |
| throw "Timed out after ${Timeout}s." | |
| } | |
| $proc.WaitForExit() | |
| $stdout = $stdoutTask.GetAwaiter().GetResult() | |
| $stderr = $stderrTask.GetAwaiter().GetResult() | |
| if ($proc.ExitCode -ne 0) { | |
| $msg = ($stderr, $stdout | Where-Object { $_ }) | Select-Object -First 1 | |
| $msg = if ($msg) { $msg.Trim() } else { "``gh api`` failed (exit $($proc.ExitCode))." } | |
| throw $msg | |
| } | |
| try { | |
| return $stdout | ConvertFrom-Json | |
| } | |
| catch { | |
| throw 'Failed to parse JSON returned by `gh api`.' | |
| } | |
| } | |
| function ConvertTo-IntIfClose { | |
| param([double]$Value) | |
| if ([Math]::Abs($Value - [Math]::Round($Value)) -lt 1e-9) { | |
| return [int][Math]::Round($Value) | |
| } | |
| return $Value | |
| } | |
| function Get-ParsedSnapshot { | |
| param($Snapshot) | |
| if ($null -eq $Snapshot) { return $null } | |
| $props = $Snapshot.PSObject.Properties | |
| if ($props['unlimited'] -and $Snapshot.unlimited) { | |
| return 'unlimited' | |
| } | |
| $total = if ($props['entitlement']) { $props['entitlement'].Value } else { $null } | |
| $remaining = if ($props['remaining']) { $props['remaining'].Value } | |
| elseif ($props['quota_remaining']) { $props['quota_remaining'].Value } | |
| else { $null } | |
| if ($null -eq $total -or $null -eq $remaining) { return $null } | |
| try { | |
| $totalF = [double]$total | |
| $remainingF = [double]$remaining | |
| } | |
| catch { return $null } | |
| return @{ | |
| Used = ConvertTo-IntIfClose ([Math]::Max($totalF - $remainingF, 0.0)) | |
| Total = ConvertTo-IntIfClose $totalF | |
| Remaining = ConvertTo-IntIfClose $remainingF | |
| } | |
| } | |
| function Get-SnapshotMetrics { | |
| param($Parsed) | |
| if ($Parsed -eq 'unlimited') { return @{ status = 'unlimited' } } | |
| if ($null -eq $Parsed) { return @{ status = 'unknown' } } | |
| $used = $Parsed.Used | |
| $total = $Parsed.Total | |
| $pct = if ([double]$total -ne 0) { [int](100 * [double]$used / [double]$total) } else { 0 } | |
| return @{ | |
| used = $used | |
| total = $total | |
| remaining = $Parsed.Remaining | |
| pct = $pct | |
| } | |
| } | |
| function Format-MetricsValue { | |
| param([hashtable]$Metrics) | |
| if ($Metrics.status -eq 'unlimited') { return 'unlimited' } | |
| if ($Metrics.status -eq 'unknown') { return 'unknown' } | |
| return "$($Metrics.used)/$($Metrics.total) - $($Metrics.pct)%" | |
| } | |
| function Format-ResetDateDdMmYy { | |
| param($Data) | |
| $props = $Data.PSObject.Properties | |
| $s = if ($props['quota_reset_date']) { $props['quota_reset_date'].Value } else { $null } | |
| if ($s -is [string] -and $s) { | |
| try { | |
| return ([datetime]::ParseExact($s, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture)).ToString('dd/MM/yy') | |
| } | |
| catch {} | |
| } | |
| $s = if ($props['quota_reset_date_utc']) { $props['quota_reset_date_utc'].Value } else { $null } | |
| if ($s -is [string] -and $s) { | |
| try { | |
| return ([datetimeoffset]::Parse($s.Replace('Z', '+00:00'))).ToString('dd/MM/yy') | |
| } | |
| catch {} | |
| } | |
| return $null | |
| } | |
| function Get-AllSnapshotMetrics { | |
| param($Data) | |
| $prop = $Data.PSObject.Properties['quota_snapshots'] | |
| if (-not $prop) { return @{} } | |
| $snapshots = $prop.Value | |
| if ($null -eq $snapshots) { return @{} } | |
| $out = @{} | |
| foreach ($p in $snapshots.PSObject.Properties) { | |
| $out[$p.Name] = Get-SnapshotMetrics (Get-ParsedSnapshot $p.Value) | |
| } | |
| return $out | |
| } | |
| function Get-Snap { | |
| param([hashtable]$Snapshots, [string]$Key) | |
| if ($Snapshots.ContainsKey($Key)) { return $Snapshots[$Key] } | |
| return @{ status = 'unknown' } | |
| } | |
| function Build-WatchRecord { | |
| param( | |
| [hashtable]$Snapshots, | |
| [string]$Reset, | |
| [switch]$Detailed, | |
| [switch]$All | |
| ) | |
| $record = [ordered]@{ | |
| timestamp = (Get-Date).ToUniversalTime().ToString('o') | |
| reset_date = $Reset | |
| } | |
| if ($All) { | |
| $record.snapshots = [ordered]@{} | |
| foreach ($k in ($Snapshots.Keys | Sort-Object)) { | |
| $record.snapshots[$k] = $Snapshots[$k] | |
| } | |
| } | |
| elseif ($Detailed) { | |
| $record.premium = Get-Snap $Snapshots 'premium_interactions' | |
| $record.chat = Get-Snap $Snapshots 'chat' | |
| $record.completions = Get-Snap $Snapshots 'completions' | |
| } | |
| else { | |
| $record.premium = Get-Snap $Snapshots 'premium_interactions' | |
| } | |
| return $record | |
| } | |
| function Get-PremiumUsed { | |
| param([hashtable]$Snapshots) | |
| $s = Get-Snap $Snapshots 'premium_interactions' | |
| if ($null -ne $s.used) { return [double]$s.used } | |
| return $null | |
| } | |
| # ── Main ── | |
| # ── Watch mode ── | |
| if ($Watch) { | |
| if (-not $LogFile) { | |
| $sid = [guid]::NewGuid().ToString('N').Substring(27) | |
| $LogFile = "ghcp-usage-$sid.jsonl" | |
| } | |
| $logPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($LogFile) | |
| $sessionStart = Get-Date | |
| $startUsed = $null | |
| $latestUsed = $null | |
| $pollCount = 0 | |
| # detect whether we can read keystrokes (false when stdin is redirected / no console) | |
| $canReadKeys = $false | |
| try { $canReadKeys = -not [Console]::IsInputRedirected } catch {} | |
| $quitHint = if ($canReadKeys) { " Press 'q' to stop." } else { ' Send SIGINT to stop.' } | |
| Write-Host "Monitoring Copilot usage every $Interval seconds.$quitHint" | |
| Write-Host "Logging JSONL to: $logPath" | |
| Write-Host '' | |
| try { | |
| while ($true) { | |
| $pollCount++ | |
| # ── Poll ── | |
| try { | |
| $data = Invoke-GhApiJson -ApiPath $ApiPath -Hostname $Hostname -Timeout $Timeout | |
| } | |
| catch { | |
| $errLine = [ordered]@{ | |
| timestamp = (Get-Date).ToUniversalTime().ToString('o') | |
| error = "$_" | |
| } | |
| $errLine | ConvertTo-Json -Depth 10 -Compress | Out-File -Append -Encoding utf8 -FilePath $logPath | |
| $line = "[$(Get-Date -Format 'HH:mm:ss')] Error: $_" | |
| Write-Host "`r$($line.PadRight([Console]::WindowWidth - 1))" -ForegroundColor Red -NoNewline | |
| $data = $null | |
| } | |
| if ($null -ne $data) { | |
| $reset = Format-ResetDateDdMmYy $data | |
| $snapshots = Get-AllSnapshotMetrics $data | |
| $record = Build-WatchRecord -Snapshots $snapshots -Reset $reset -Detailed:$Detailed -All:$All | |
| # Append JSONL | |
| $record | ConvertTo-Json -Depth 10 -Compress | Out-File -Append -Encoding utf8 -FilePath $logPath | |
| # Track session usage | |
| $currentUsed = Get-PremiumUsed $snapshots | |
| if ($null -ne $currentUsed) { | |
| if ($null -eq $startUsed) { $startUsed = $currentUsed } | |
| $latestUsed = $currentUsed | |
| } | |
| $premiumDisplay = Format-MetricsValue (Get-Snap $snapshots 'premium_interactions') | |
| $delta = if ($null -ne $startUsed -and $null -ne $latestUsed) { | |
| $d = [Math]::Max($latestUsed - $startUsed, 0) | |
| " | session: +$d" | |
| } else { '' } | |
| $line = "[$(Get-Date -Format 'HH:mm:ss')] $premiumDisplay$delta" | |
| Write-Host "`r$($line.PadRight([Console]::WindowWidth - 1))" -NoNewline | |
| } | |
| # ── Interruptible sleep: check for 'q' every 250ms ── | |
| $elapsed = [System.Diagnostics.Stopwatch]::StartNew() | |
| while ($elapsed.Elapsed.TotalSeconds -lt $Interval) { | |
| if ($canReadKeys -and [Console]::KeyAvailable) { | |
| $key = [Console]::ReadKey($true) | |
| if ($key.KeyChar -in 'q', 'Q') { | |
| throw '__quit__' | |
| } | |
| } | |
| $remainingMs = [Math]::Max(1, [int](($Interval - $elapsed.Elapsed.TotalSeconds) * 1000)) | |
| Start-Sleep -Milliseconds ([Math]::Min(250, $remainingMs)) | |
| } | |
| } | |
| } | |
| catch { | |
| if ($_.Exception.Message -ne '__quit__') { | |
| throw | |
| } | |
| } | |
| # ── Session summary ── | |
| $sessionEnd = Get-Date | |
| $duration = $sessionEnd - $sessionStart | |
| Write-Host '' | |
| Write-Host '' | |
| Write-Host '── Session Summary ──' -ForegroundColor Cyan | |
| Write-Host " Start: $($sessionStart.ToString('yyyy-MM-dd HH:mm:ss'))" | |
| Write-Host " End: $($sessionEnd.ToString('yyyy-MM-dd HH:mm:ss'))" | |
| Write-Host " Duration: $([math]::Floor($duration.TotalHours))h $($duration.Minutes)m $($duration.Seconds)s" | |
| Write-Host " Polls: $pollCount" | |
| if ($null -ne $startUsed -and $null -ne $latestUsed) { | |
| $sessionDelta = [Math]::Max($latestUsed - $startUsed, 0) | |
| Write-Host " Premium requests used this session: $sessionDelta ($startUsed -> $latestUsed)" | |
| } | |
| else { | |
| Write-Host ' Premium requests used this session: unknown (could not read usage)' | |
| } | |
| Write-Host " Log file: $logPath" | |
| exit 0 | |
| } | |
| # ── One-shot mode ── | |
| try { | |
| $data = Invoke-GhApiJson -ApiPath $ApiPath -Hostname $Hostname -Timeout $Timeout | |
| } | |
| catch { | |
| [Console]::Error.WriteLine("Error: $($_.Exception.Message)") | |
| exit 1 | |
| } | |
| $reset = Format-ResetDateDdMmYy $data | |
| $resetSuffix = if ($reset) { " - $reset" } else { '' } | |
| $snapshots = Get-AllSnapshotMetrics $data | |
| if ($Json) { | |
| if ($All) { | |
| $payload = [ordered]@{ | |
| mode = 'all' | |
| reset_date = $reset | |
| snapshots = [ordered]@{} | |
| } | |
| foreach ($k in ($snapshots.Keys | Sort-Object)) { | |
| $payload.snapshots[$k] = $snapshots[$k] | |
| } | |
| } | |
| elseif ($Detailed) { | |
| $payload = [ordered]@{ | |
| mode = 'detailed' | |
| reset_date = $reset | |
| premium = Get-Snap $snapshots 'premium_interactions' | |
| chat = Get-Snap $snapshots 'chat' | |
| completions = Get-Snap $snapshots 'completions' | |
| } | |
| } | |
| else { | |
| $payload = [ordered]@{ | |
| mode = 'default' | |
| reset_date = $reset | |
| premium = Get-Snap $snapshots 'premium_interactions' | |
| } | |
| } | |
| $payload | ConvertTo-Json -Depth 10 | |
| exit 0 | |
| } | |
| if ($Detailed) { | |
| Write-Output "premium: $(Format-MetricsValue (Get-Snap $snapshots 'premium_interactions'))" | |
| Write-Output "chat: $(Format-MetricsValue (Get-Snap $snapshots 'chat'))" | |
| Write-Output "completions: $(Format-MetricsValue (Get-Snap $snapshots 'completions'))" | |
| Write-Output "reset date: $(if ($reset) { $reset } else { 'unknown' })" | |
| exit 0 | |
| } | |
| if ($All) { | |
| foreach ($k in ($snapshots.Keys | Sort-Object)) { | |
| Write-Output "${k}: $(Format-MetricsValue $snapshots[$k])" | |
| } | |
| Write-Output "reset date: $(if ($reset) { $reset } else { 'unknown' })" | |
| exit 0 | |
| } | |
| $premiumValue = Format-MetricsValue (Get-Snap $snapshots 'premium_interactions') | |
| Write-Output "${premiumValue}${resetSuffix}" | |
| exit 0 |