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