CodexResets.psm1
|
function Get-CodexResets { <# .SYNOPSIS Show Codex reset-credit grant and expiry dates from your local Codex auth. .DESCRIPTION PowerShell port of zorbeytorunoglu/codex-resets (https://github.com/zorbeytorunoglu/codex-resets). Reads your local Codex auth.json, calls the same (unofficial, undocumented) reset-credit backend endpoint, and prints the grant and expiry dates for available reset credits. This is an unofficial local inspection tool. The endpoint is not a public API and may change or disappear without notice. .PARAMETER Json Print stable, redacted JSON for scripts. .PARAMETER Raw Print the raw endpoint response for local troubleshooting. May include account-specific metadata. Do not share blindly. .PARAMETER WarnHours Warn when a reset expires within this many hours. Default: 48. .PARAMETER AuthPath Read a specific Codex auth.json file instead of the default lookup. .PARAMETER Version Show script version. .EXAMPLE Get-CodexResets .EXAMPLE Get-CodexResets -Json .EXAMPLE Get-CodexResets -WarnHours 12 -AuthPath "$HOME\.codex\auth.json" .NOTES Auth lookup order: 1. -AuthPath <path> 2. $env:CODEX_HOME\auth.json 3. ~/.codex/auth.json Security: this script reads auth.json locally at runtime. It does not copy, cache, or upload your token anywhere except as the bearer token required for the single Codex backend request. Default and -Json output never include your access token, account ID, auth headers, or auth file contents. -Raw may include account-specific metadata from the backend response and should not be pasted publicly. #> [CmdletBinding()] param( [switch]$Json, [switch]$Raw, [double]$WarnHours = 48, [string]$AuthPath, [switch]$Version ) $ErrorActionPreference = 'Stop' $ScriptVersion = '0.1.0' $ResetCreditsEndpoint = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits' class CodexResetsError : System.Exception { CodexResetsError([string]$message) : base($message) {} } function Write-Stderr { param([string]$Message) [Console]::Error.WriteLine($Message) } function Resolve-AuthPath { param( [string]$AuthPathParam, [string]$CodexHomeEnv, [string]$HomeDir ) if (-not [string]::IsNullOrWhiteSpace($AuthPathParam)) { $expanded = $AuthPathParam.Trim() if ($expanded -eq '~') { $expanded = $HomeDir } elseif ($expanded.StartsWith('~/') -or $expanded.StartsWith('~\')) { $expanded = Join-Path $HomeDir $expanded.Substring(2) } return [System.IO.Path]::GetFullPath($expanded) } if (-not [string]::IsNullOrWhiteSpace($CodexHomeEnv)) { $expandedHome = $CodexHomeEnv.Trim() if ($expandedHome -eq '~') { $expandedHome = $HomeDir } elseif ($expandedHome.StartsWith('~/') -or $expandedHome.StartsWith('~\')) { $expandedHome = Join-Path $HomeDir $expandedHome.Substring(2) } $resolvedHome = [System.IO.Path]::GetFullPath($expandedHome) return Join-Path $resolvedHome 'auth.json' } return Join-Path (Join-Path $HomeDir '.codex') 'auth.json' } function Get-CodexAuth { param( [string]$AuthPathParam ) $homeDir = if ($IsWindows) { $env:USERPROFILE } else { $env:HOME } if ([string]::IsNullOrWhiteSpace($homeDir)) { $homeDir = [System.Environment]::GetFolderPath('UserProfile') } $resolvedPath = Resolve-AuthPath -AuthPathParam $AuthPathParam -CodexHomeEnv $env:CODEX_HOME -HomeDir $homeDir if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { throw [CodexResetsError]::new("Missing auth file: $resolvedPath") } $text = $null try { $text = Get-Content -LiteralPath $resolvedPath -Raw -ErrorAction Stop } catch { throw [CodexResetsError]::new("Could not read auth file: $resolvedPath`: $($_.Exception.Message)") } $parsed = $null try { $parsed = $text | ConvertFrom-Json -ErrorAction Stop } catch { throw [CodexResetsError]::new("Auth file is not valid JSON: $resolvedPath") } if ($null -eq $parsed -or $null -eq $parsed.tokens) { throw [CodexResetsError]::new("Auth file is missing tokens object: $resolvedPath") } $accessToken = $parsed.tokens.access_token $accountId = $parsed.tokens.account_id if ([string]::IsNullOrEmpty($accessToken)) { throw [CodexResetsError]::new('Auth file contains an empty or invalid access token.') } if ([string]::IsNullOrEmpty($accountId)) { throw [CodexResetsError]::new('Auth file contains an empty or invalid account ID.') } return [PSCustomObject]@{ AccessToken = $accessToken AccountId = $accountId AuthPath = $resolvedPath } } function Invoke-ResetCreditsFetch { param( [string]$AccessToken, [string]$AccountId ) $headers = @{ 'Authorization' = "Bearer $AccessToken" 'ChatGPT-Account-ID' = $AccountId 'OpenAI-Beta' = 'codex-1' 'originator' = 'Codex Desktop' } try { $response = Invoke-WebRequest -Uri $ResetCreditsEndpoint -Method Get -Headers $headers -TimeoutSec 15 -UseBasicParsing -ErrorAction Stop } catch [System.Net.WebException] { throw [CodexResetsError]::new("Could not reach reset-credit endpoint: $($_.Exception.Message)") } catch { # Invoke-WebRequest throws on non-2xx in modern PowerShell; surface status + body if available. $resp = $_.Exception.Response if ($resp) { $statusCode = [int]$resp.StatusCode $bodyText = '' try { $stream = $resp.GetResponseStream() $reader = New-Object System.IO.StreamReader($stream) $bodyText = $reader.ReadToEnd() } catch { } $detail = $bodyText.Trim() if ($detail.Length -gt 500) { $detail = $detail.Substring(0, 500) + '...' } $suffix = if ($detail) { ": $detail" } else { '' } throw [CodexResetsError]::new("Endpoint returned HTTP $statusCode$suffix") } throw [CodexResetsError]::new("Could not reach reset-credit endpoint: $($_.Exception.Message)") } $bodyRaw = $response.Content if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { $detail = $bodyRaw.Trim() if ($detail.Length -gt 500) { $detail = $detail.Substring(0, 500) + '...' } $suffix = if ($detail) { ": $detail" } else { '' } throw [CodexResetsError]::new("Endpoint returned HTTP $([int]$response.StatusCode)$suffix") } $data = $null try { $data = $bodyRaw | ConvertFrom-Json -ErrorAction Stop } catch { throw [CodexResetsError]::new('Endpoint returned non-JSON data.') } if ($null -eq $data -or $data -is [System.Array]) { throw [CodexResetsError]::new('Endpoint response shape changed: expected a JSON object.') } return $data } function ConvertTo-ParsedDate { param( [object]$Value, [string]$FieldName ) if ($null -eq $Value) { throw [CodexResetsError]::new("Endpoint response shape changed: $FieldName is missing or not a string.") } # ConvertFrom-Json auto-converts ISO 8601 strings to [DateTime] on some PowerShell versions # (observed on Windows PowerShell / certain PS7 builds), while others leave it as [string]. # Accept either representation. if ($Value -is [DateTime]) { $dt = [DateTime]$Value # ConvertFrom-Json sometimes returns Kind=Unspecified for what was actually a UTC # ("Z"-suffixed) ISO string. Treat Unspecified and Utc both as UTC to avoid silently # shifting the timestamp; only Local is treated as already-correct local time. if ($dt.Kind -eq [DateTimeKind]::Local) { return [DateTimeOffset]::new($dt) } $utcDt = [DateTime]::SpecifyKind($dt, [DateTimeKind]::Utc) return [DateTimeOffset]::new($utcDt).ToLocalTime() } if ($Value -is [DateTimeOffset]) { return ([DateTimeOffset]$Value).ToLocalTime() } if (-not ($Value -is [string]) -or $Value.Length -eq 0) { throw [CodexResetsError]::new("Endpoint response shape changed: $FieldName is missing or not a string.") } try { return [DateTimeOffset]::Parse($Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind).ToLocalTime() } catch { throw [CodexResetsError]::new("Endpoint response shape changed: $FieldName is not an ISO datetime.") } } function ConvertTo-ParsedResetCredits { param([object]$Data) if ($null -eq $Data) { throw [CodexResetsError]::new('Endpoint response shape changed: expected a JSON object.') } $parsedCount = 0 $countOk = ($null -ne $Data.available_count) -and [int]::TryParse([string]$Data.available_count, [ref]$parsedCount) if (-not $countOk) { throw [CodexResetsError]::new('Endpoint response shape changed: available_count is missing or not an integer.') } # ConvertFrom-Json yields a single PSCustomObject (not an array) when the JSON array has exactly # one element, and $null when the property is absent/JSON null. Treat both correctly as "not a list". if ($null -eq $Data.credits) { throw [CodexResetsError]::new('Endpoint response shape changed: credits is missing or not a list.') } $creditsRaw = @($Data.credits) $credits = @() $index = 0 foreach ($credit in $creditsRaw) { $index++ if ($null -eq $credit) { throw [CodexResetsError]::new("Endpoint response shape changed: credits[$index] is not an object.") } $grantedAt = ConvertTo-ParsedDate -Value $credit.granted_at -FieldName "credits[$index].granted_at" $expiresAt = ConvertTo-ParsedDate -Value $credit.expires_at -FieldName "credits[$index].expires_at" $title = if ($credit.title -is [string] -and $credit.title.Length -gt 0) { $credit.title } else { "Reset credit $index" } $status = if ($credit.status -is [string] -and $credit.status.Length -gt 0) { $credit.status } else { 'unknown' } $resetType = if ($credit.reset_type -is [string] -and $credit.reset_type.Length -gt 0) { $credit.reset_type } else { 'unknown' } $credits += [PSCustomObject]@{ Title = $title Status = $status ResetType = $resetType GrantedAt = $grantedAt ExpiresAt = $expiresAt } } $credits = $credits | Sort-Object { $_.ExpiresAt.UtcDateTime } return [PSCustomObject]@{ AvailableCount = [int]$Data.available_count Credits = $credits } } function Format-OffsetLabel { param([DateTimeOffset]$Date) $offset = $Date.Offset $sign = if ($offset.Ticks -ge 0) { '+' } else { '-' } $abs = [TimeSpan]::FromTicks([Math]::Abs($offset.Ticks)) $hours = [int]$abs.Hours $minutes = [int]$abs.Minutes if ($minutes -eq 0) { return "$sign$('{0:D2}' -f $hours)" } return "$sign$('{0:D2}' -f $hours):$('{0:D2}' -f $minutes)" } function Format-LocalDate { param([DateTimeOffset]$Date) $offsetLabel = Format-OffsetLabel -Date $Date return $Date.ToString('yyyy-MM-dd HH:mm:ss') + ' ' + $offsetLabel } function Format-DurationLabel { param([double]$Milliseconds) $totalSeconds = [Math]::Floor([Math]::Abs($Milliseconds) / 1000) $days = [Math]::Floor($totalSeconds / 86400) $hours = [Math]::Floor(($totalSeconds % 86400) / 3600) $minutes = [Math]::Floor(($totalSeconds % 3600) / 60) $parts = @() if ($days -gt 0) { $parts += "$days day$(if ($days -eq 1) { '' } else { 's' })" } if ($hours -gt 0 -or $days -gt 0) { $parts += "$hours hr$(if ($hours -eq 1) { '' } else { 's' })" } $parts += "$minutes min$(if ($minutes -eq 1) { '' } else { 's' })" return ($parts -join ', ') } function Get-TimeLeftLabel { param([DateTimeOffset]$ExpiresAt, [DateTimeOffset]$Now) $remainingMs = ($ExpiresAt - $Now).TotalMilliseconds if ($remainingMs -lt 0) { return "expired $(Format-DurationLabel -Milliseconds $remainingMs) ago" } return Format-DurationLabel -Milliseconds $remainingMs } function ConvertTo-PublicReport { param( [object]$Report, [DateTimeOffset]$Now, [double]$WarnHoursValue ) $warnMs = $WarnHoursValue * 3600000 $creditsOut = $Report.Credits | ForEach-Object { $remainingMs = ($_.ExpiresAt - $Now).TotalMilliseconds [PSCustomObject]@{ title = $_.Title status = $_.Status resetType = $_.ResetType grantedAt = $_.GrantedAt.UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') expiresAt = $_.ExpiresAt.UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') timeLeftSeconds = Get-TimeLeftLabel -ExpiresAt $_.ExpiresAt -Now $Now expiresWithinWarnHours = ($remainingMs -ge 0 -and $remainingMs -le $warnMs) } } return [PSCustomObject]@{ availableCount = $Report.AvailableCount generatedAt = $Now.UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') warnHours = $WarnHoursValue credits = @($creditsOut) } } function ConvertTo-ResetCreditOutput { param( [object]$PublicReport ) return $PublicReport.credits | ForEach-Object { $output = [PSCustomObject]@{ PSTypeName = 'CodexResets.ResetCredit' Avail = $PublicReport.availableCount Expires = [DateTimeOffset]::Parse($_.expiresAt).ToString('MM-dd HH:mm') Left = $_.timeLeftSeconds Warn = $_.expiresWithinWarnHours AvailableCount = $PublicReport.availableCount WarnHours = $PublicReport.warnHours GeneratedAt = [DateTimeOffset]::Parse($PublicReport.generatedAt) Title = $_.title Status = $_.status ResetType = $_.resetType GrantedAt = [DateTimeOffset]::Parse($_.grantedAt) ExpiresAt = [DateTimeOffset]::Parse($_.expiresAt) TimeLeftSeconds = $_.timeLeftSeconds ExpiresWithinWarnHours = $_.expiresWithinWarnHours } $defaultDisplay = [System.Management.Automation.PSPropertySet]::new( 'DefaultDisplayPropertySet', [string[]]@('Avail', 'Title', 'Expires', 'Left') ) $standardMembers = [System.Management.Automation.PSMemberInfo[]]@($defaultDisplay) $output | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $standardMembers $output } } function Format-Report { param( [object]$Report, [DateTimeOffset]$Now, [double]$WarnHoursValue ) $lines = New-Object System.Collections.Generic.List[string] $lines.Add("Resets available: $($Report.AvailableCount)") if ($Report.Credits.Count -eq 0) { $lines.Add('No reset credits returned by the endpoint.') return ($lines -join "`n") } $warnMs = $WarnHoursValue * 3600000 $expiringSoon = New-Object System.Collections.Generic.List[object] $lines.Add('') $total = $Report.Credits.Count for ($i = 0; $i -lt $total; $i++) { $credit = $Report.Credits[$i] $displayIndex = $i + 1 $remainingMs = ($credit.ExpiresAt - $Now).TotalMilliseconds $remainingLabel = Get-TimeLeftLabel -ExpiresAt $credit.ExpiresAt -Now $Now if ($remainingMs -ge 0 -and $remainingMs -le $warnMs) { $expiringSoon.Add([PSCustomObject]@{ DisplayIndex = $displayIndex; RemainingLabel = $remainingLabel }) } $lines.Add("$displayIndex. $($credit.Title)") $lines.Add(" Status: $($credit.Status)") $lines.Add(" Type: $($credit.ResetType)") $lines.Add(" Granted: $(Format-LocalDate -Date $credit.GrantedAt)") $lines.Add(" Expires: $(Format-LocalDate -Date $credit.ExpiresAt)") $lines.Add(" Time left: $remainingLabel") if ($i -ne $total - 1) { $lines.Add('') } } if ($expiringSoon.Count -gt 0) { $lines.Add('') foreach ($item in $expiringSoon) { $lines.Add("WARNING: reset #$($item.DisplayIndex) expires within $WarnHoursValue hours ($($item.RemainingLabel) left).") } } return ($lines -join "`n") } function Write-Stdout { param([object]$Message) Write-Output $Message } function Main { if ($Version) { return $ScriptVersion } if ($Json -and $Raw) { Write-Stderr 'codex-resets: Use either -Json or -Raw, not both.' Write-Stderr 'codex-resets: run Get-Help Get-CodexResets for usage.' return 1 } if ($WarnHours -lt 0) { Write-Stderr 'codex-resets: -WarnHours must be a non-negative number.' return 1 } try { $auth = Get-CodexAuth -AuthPathParam $AuthPath $data = Invoke-ResetCreditsFetch -AccessToken $auth.AccessToken -AccountId $auth.AccountId if ($Raw) { Write-Stderr 'WARNING: raw output may include account-specific metadata. Do not share blindly.' return ($data | ConvertTo-Json -Depth 10) } $report = ConvertTo-ParsedResetCredits -Data $data $now = [DateTimeOffset]::Now if ($Json) { $publicReport = ConvertTo-PublicReport -Report $report -Now $now -WarnHoursValue $WarnHours return ($publicReport | ConvertTo-Json -Depth 10) } $publicReport = ConvertTo-PublicReport -Report $report -Now $now -WarnHoursValue $WarnHours return ConvertTo-ResetCreditOutput -PublicReport $publicReport } catch [CodexResetsError] { Write-Stderr "codex-resets: $($_.Exception.Message)" Write-Stderr 'codex-resets: run Get-Help Get-CodexResets for usage.' return 1 } catch { Write-Stderr "codex-resets: unexpected error: $($_.Exception.Message)" return 1 } } Main } Export-ModuleMember -Function Get-CodexResets |