Skill: teamtelnyx/telnyx-freemium-upgrade (version 1.0.0)

--- name: telnyx-freemium-upgrade description: "Automatically upgrade Telnyx account from freemium to professional tier" metadata: {"openclaw":{"emoji":"⬆️","requires":{"bins":["gh","python3"],"env":[]},"primaryEnv":"TELNYX_API_KEY","notes":"API key resolved from TELNYX_API_KEY env var or ~/.config/telnyx/config.json"}} --- # Telnyx Professional Upgrade Skill ## Companion Skills This skill depends on **telnyx-bot-signup** for API key generation and refresh. If no Telnyx API key is available, or if the key needs to be refreshed after a successful upgrade, use `telnyx-bot-signup` to obtain one. The `bot_signup` endpoint handles both new and existing accounts — just provide the user's email. | Skill | When needed | Install | |-------|------------|---------| | **telnyx-bot-signup** | No API key available, or key refresh needed post-upgrade | `clawhub install telnyx-bot-signup` | --- ## When to Activate This skill triggers when the bot encounters a **freemium-blocked operation**. Activate when ANY of these conditions occur: ### Automatic Triggers (API Error Codes) | Error Code | Context | Meaning | |------------|---------|---------| | `10039` | SMS send | Destination not verified (freemium restriction) | | `D60` | Voice call | Destination not verified (freemium restriction) | | `10038` | Various | Feature not available on current plan (porting, short codes, number lookup, SIM, managed accounts, etc.) | ### Heuristic Triggers (Capacity Limits) - Phone number order fails after the first purchase (1 number limit) - Balance/credit check returns low available credit ($10 ceiling) - **Inability to add funds / top up balance** — freemium accounts cannot add balance or payment methods. If the bot encounters an error when trying to add funds, check account tier, or top up, and the account is freemium, this is a strong signal that an upgrade is needed. - Account shows as "inactive" or "deactivated" due to $0 balance on freemium tier ### Explicit Triggers - User requests an upgrade: "upgrade my account", "I need professional tier" - Bot needs a non-freemium feature: "send SMS to unverified number", "port a number", "create a managed account" - User or bot tries to add balance/payment and is blocked by freemium restrictions --- ## Verification Methods Two verification methods are available. Each can only be used once per account. | Method | Flag | Browser? | Best for | |--------|------|----------|----------| | **GitHub** | `--github-token <TOKEN>` | No (automated) | Users with `gh` CLI installed | | **LinkedIn** | `--linkedin` | Yes (OAuth consent) | Users without GitHub, or after GitHub rejection | ### Method Selection Decision Tree ``` Which method to try? ├── Check ~/.telnyx/upgrade.json cache │ ├── decision: APPROVED → Already upgraded, skip │ ├── decision: PASS_TO_HUMAN → Under review, don't retry │ └── decision: REJECTED or no cache → Continue │ ├── Check used_methods in cache or status endpoint │ ├── github_oauth NOT used → Try GitHub first (automated, no browser) │ ├── github_oauth used, linkedin_oauth NOT used → Try LinkedIn │ └── Both used → Both methods exhausted, contact support │ └── No preference → Try GitHub first (faster, no browser needed) ``` ### LinkedIn Verification Method **When to use:** User has no GitHub account, GitHub method was REJECTED, or user prefers LinkedIn. **Flow:** ``` 1. python3 {baseDir}/scripts/evaluate-upgrade.py --linkedin → Returns { action: "open_url", url: "<linkedin_oauth_url>" } 2. Present the URL to the user: "To verify your identity, please open this link and sign in with LinkedIn: <url> After authorizing, you can close the browser tab." 3. The script polls automatically. When the evaluation completes, it outputs the result JSON (same format as GitHub). 4. Handle outcome: APPROVED / REJECTED / PASS_TO_HUMAN (same as GitHub) ``` **Notification templates for LinkedIn URL:** **Telegram:** ``` 🔗 Telnyx Account Upgrade — LinkedIn Verification I need to verify your identity via LinkedIn to upgrade your Telnyx account. This is a one-time step. 👉 Open: <URL> ⏰ Link expires in 10 minutes Sign in with your LinkedIn account when prompted. After authorizing, you can close the tab. ``` **Slack:** ``` :link: *Telnyx Account Upgrade — LinkedIn Verification* I need to verify your identity via LinkedIn to upgrade your Telnyx account. This is a one-time step. :point_right: <URL|Open LinkedIn Verification> :timer_clock: Link expires in 10 minutes Sign in with your LinkedIn account when prompted. After authorizing, you can close the tab. ``` --- ## Pre-flight Checks (ALWAYS do these first) Before submitting a new evaluation: ### 1. Check Local Cache Read `~/.telnyx/upgrade.json`. If it exists: - **`decision: "APPROVED"`** → Skip evaluation entirely. The account is already upgraded. Retry the blocked operation directly. - **`decision: "PASS_TO_HUMAN"`** → Re-poll the status endpoint with the cached `evaluation_id` to check if a decision was made. Do NOT submit a new evaluation. - **`decision: "REJECTED"`** → Check `used_methods`: - Only `github_oauth` used → Try LinkedIn: `python3 {baseDir}/scripts/evaluate-upgrade.py --linkedin` - Only `linkedin_oauth` used → Try GitHub: `python3 {baseDir}/scripts/evaluate-upgrade.py --github-token <TOKEN>` - Both used → All methods exhausted. Contact support. - **`status: "polling_timeout"`** → Re-poll the cached `evaluation_id`. The evaluation may have completed server-side. ### 2. Check `used_methods` via Status Endpoint If no cache or cache is stale, poll `GET /v2/account/upgrade-request-status/{evaluation_id}` (if you have an evaluation_id) or check the latest evaluation. Check which methods have been used and follow the decision tree above. --- ## Token Type Detection and Handling Run `{baseDir}/scripts/check-gh-auth.sh` to get token info. Handle based on `token_type`: ### `ghs_` (GitHub App Installation Token) → ABORT These tokens represent a GitHub App, NOT a human user. Cannot verify human identity. **Tell the user:** > Your GitHub CLI is authenticated with a GitHub App installation token (commonly used in CI/CD environments). This token type cannot access user profile data needed for identity verification. > > To proceed, please authenticate with your personal GitHub account: > ``` > gh auth login --web > ``` > This will not affect your CI/CD workflows — gh supports multiple accounts. **STOP** — wait for user to re-authenticate, then retry. ### `github_pat_` (Fine-grained PAT) → WARN and PROCEED Fine-grained PATs have limited API access: `/user/orgs` returns empty, no GraphQL support. Evaluation will have degraded data. **Tell the user:** > Your GitHub CLI is using a fine-grained personal access token. Some profile data (organizations, GraphQL contributions) may be unavailable, which could affect your evaluation. For best results, run `gh auth login --web` to use a standard OAuth token. **PROCEED** with the token anyway — the evaluator handles partial data. ### `gho_` or `ghp_` (OAuth / Classic PAT) → CHECK SCOPES and PROCEED These are compatible token types. Check scopes and refresh if needed. --- ## Scope Check and Refresh Required scopes: `user`, `read:org` If `check-gh-auth.sh` returns `missing_scopes` that is non-empty: 1. Run `{baseDir}/scripts/refresh-gh-scopes.sh` 2. Check the output: - `success: true` → scopes refreshed without browser interaction. Continue to submission. - `requires_browser: true` + `success: false` → the `device_code`, `verification_uri`, and `pid` fields are returned. Deliver the device code to the user via the **Notification Decision Tree** below. 3. After the user authorizes, run `{baseDir}/scripts/wait-for-auth.sh` to confirm the refresh completed: - `success: true` → scopes refreshed. Continue to submission. - `success: false` → device code expired or authorization was denied. Retry from step 1 (max 3 times). You can also pass the PID directly: `bash {baseDir}/scripts/wait-for-auth.sh --pid <PID>` --- ## Notification Decision Tree (Scope Refresh) When the bot needs a scope refresh or token acquisition: ``` START: Bot needs GitHub scope refresh │ ├── Is `gh` installed? │ ├── NO → Notify user: "Install gh: https://cli.github.com" │ │ OR offer manual PAT creation via github.com/settings/tokens │ │ INTERVENTION: MEDIUM │ │ │ └── YES → Check token type (prefix) │ │ │ ├── ghs_ (installation) → ABORT │ │ Notify user: "Need personal GitHub auth, not CI/CD token" │ │ INTERVENTION: MEDIUM │ │ │ ├── github_pat_ (fine-grained) → WARN, proceed with degraded data │ │ Cannot refresh scopes on PATs → proceed as-is │ │ INTERVENTION: ZERO (but evaluation has less data) │ │ │ └── gho_ / ghp_ (OAuth / classic PAT) → Check scopes │ │ │ ├── Scopes OK → PROCEED directly to upgrade evaluation │ │ INTERVENTION: ZERO │ │ │ └── Scopes missing → Need refresh │ │ │ └── Send device code to user's active channel │ │ │ ├── User reachable on Telegram/Slack/etc? │ │ ├── YES → Send code + link (see Cross-Channel Notification) │ │ │ INTERVENTION: MINIMAL (user taps link, enters code) │ │ │ │ │ └── NO (offline) → Save state + heartbeat │ │ INTERVENTION: DEFERRED ``` ### Cross-Channel Notification (Minimal Intervention) Parse the device code from `refresh-gh-scopes.sh` output (`device_code` and `verification_uri` fields). Send to the user's active channel: **Telegram:** ``` 🔑 Telnyx Account Upgrade I need to verify your GitHub identity to upgrade your Telnyx account from freemium to professional tier. This is a one-time step. 👉 Visit: https://github.com/login/device 📝 Enter code: <DEVICE_CODE> ⏰ Expires in 15 minutes Tap the link above to open in your browser. You must be logged into GitHub. ``` **Slack:** ``` :key: *Telnyx Account Upgrade* I need to verify your GitHub identity to upgrade your Telnyx account. This is a one-time step. :point_right: <https://github.com/login/device|Open GitHub Device Authorization> :pencil: Enter code: `<DEVICE_CODE>` :timer_clock: Expires in 15 minutes You must be logged into GitHub in your browser. ``` **SMS (last resort):** ``` Telnyx upgrade: enter code <DEVICE_CODE> at github.com/login/device within 15 min. Must be logged into GitHub. ``` Use the user's **last active channel** (check OpenClaw config at `~/.openclaw/openclaw.json`). Priority: Telegram > Slack > SMS. ### Deferred (User Offline) If the user is unreachable: 1. Save state to `~/.telnyx/upgrade.json`: ```json { "pending_action": "scope_refresh", "reason": "missing_scopes", "required_scopes": ["user", "read:org"], "queued_at": "<ISO_TIMESTAMP>", "original_task": "<description of what triggered the upgrade>" } ``` 2. On user return (next message to bot), respond: > Before we continue, I need to upgrade your Telnyx account to use [requested feature]. This requires a one-time GitHub verification. Ready to proceed? --- ## Proactive Upgrade (Optional) On first Telnyx API interaction or skill load, the bot MAY proactively check the account tier: 1. If freemium and no prior attempt: - If `gho_` token with `user` scope already present → start upgrade silently in background. Notify user only on completion. - If scopes missing but token compatible → offer: "I notice you're on Telnyx's freemium tier. Some features you might need require an upgrade. I can handle this now (takes ~2 min, needs GitHub verification) or we can do it later when you actually need it. Want me to proceed?" - If `gh` not installed or `ghs_` token → queue for later, don't interrupt. 2. If prior APPROVED → no action needed. 3. If prior REJECTED → inform only when user hits a freemium wall. 4. If prior PASS_TO_HUMAN → re-poll silently. ### Intent Analysis (Before API Call) The bot can analyze the user's intent before making a Telnyx API call to proactively trigger the upgrade: | User says | Bot infers | Action | |-----------|-----------|--------| | "Send SMS to +1-555-123-4567" | Will need non-freemium (unverified destination) | Check tier → pre-upgrade if needed | | "Port my number from AT&T" | Will need non-freemium (porting blocked) | Check tier → pre-upgrade if needed | | "Buy 5 phone numbers" | Will need non-freemium (1 number limit) | Check tier → pre-upgrade if needed | | "Set up a SIP trunk" | Will need non-freemium (SIM/SIP blocked) | Check tier → pre-upgrade if needed | | "Create a managed account" | Will need non-freemium (10038) | Check tier → pre-upgrade if needed | | "Check my balance" | Freemium OK | No action | | "Buy a phone number" | Freemium OK (1st number) | No action | --- ## API Key Resolution The Telnyx API key is resolved in this order: 1. **`TELNYX_API_KEY` environment variable** — checked first 2. **`~/.config/telnyx/config.json`** — fallback (written by `telnyx auth setup` from the telnyx-cli skill) To read from the config file: ```bash [REDACTED_SECRET] -c "import json; print(json.load(open('$HOME/.config/telnyx/config.json'))['api_key'])" 2>/dev/null || echo '')}" ``` If neither source has a key, hand off to the **telnyx-bot-signup** skill to generate one. The `bot_signup` endpoint works for both new and existing accounts — it sends a magic sign-in link to the user's email and produces a fresh API key. If `telnyx-bot-signup` is not installed: > No Telnyx API key found. Install the signup skill (`clawhub install telnyx-bot-signup`) or set the `TELNYX_API_KEY` environment variable. --- ## Submission and Polling ### GitHub Method Once the GitHub token is ready: 1. Resolve the Telnyx API key (see **API Key Resolution** above). 2. Run `{baseDir}/scripts/get-gh-token.sh` to extract the GitHub token. 3. Run `{baseDir}/scripts/evaluate-upgrade.py --github-token <TOKEN> --api-key <API_KEY>` 4. The script handles: submission → polling (every 5s, max 120s) → caching → structured output. ### LinkedIn Method 1. Resolve the Telnyx API key (see **API Key Resolution** above). 2. Run `{baseDir}/scripts/evaluate-upgrade.py --linkedin --api-key <API_KEY>` 3. The script outputs an `open_url` action with the LinkedIn OAuth URL. 4. Present the URL to the user (see notification templates above). 5. The script automatically polls for the evaluation to appear and complete. 6. Output is the same JSON format as the GitHub method. --- ## Response Templates (Per Outcome) ### APPROVED > Your Telnyx account has been upgraded to the professional tier! Retrying your request... Then retry the original blocked operation. If the retry still fails with the same error, the API key needs to be refreshed to pick up professional-tier permissions. Use **telnyx-bot-signup** to generate a fresh key (same email, sign-in flow) rather than asking the user to visit the portal. ### REJECTED Check the `used_methods` and `next_steps` fields in the script output: - **GitHub only used** → Offer LinkedIn: > Your GitHub-based upgrade was not approved. I can try verifying your identity via LinkedIn instead. This requires opening a link in your browser. Want me to proceed? - **LinkedIn only used** → Offer GitHub: > Your LinkedIn-based upgrade was not approved. I can try verifying your identity via GitHub instead. Want me to proceed? - **Both methods used** → > Your professional upgrade request was not approved via either verification method. > > Please contact Telnyx support: https://support.telnyx.com > > The original task requiring professional features cannot proceed at this time. ### PASS_TO_HUMAN > Your upgrade application is under manual review by the Telnyx team. A support ticket has been created. I've set up automatic status checks — you'll be notified as soon as a decision is made. > > In the meantime, freemium features remain available. **Set up a cron job to poll the status automatically.** Use the OpenClaw cron system: ```bash openclaw cron add \ --name "telnyx-upgrade-poll" \ --every 3600000 \ --session isolated \ --message "Run: python3 <SKILL_SCRIPTS_DIR>/evaluate-upgrade.py --poll-only --evaluation-id <EVALUATION_ID> --api-key $TELNYX_API_KEY. If the decision is APPROVED, tell me the account has been upgraded and retry the original blocked operation. If REJECTED, tell me it was not approved and suggest contacting support. If still PASS_TO_HUMAN / under review, do nothing — the next poll will check again." \ --announce \ --channel last ``` Replace `<EVALUATION_ID>` with the cached evaluation ID and `<SKILL_SCRIPTS_DIR>` with the absolute path to this skill's scripts directory. **Polling schedule:** Every 1 hour (3600000ms). Manual review typically resolves within minutes to hours, so hourly is a good balance between responsiveness and not hammering the API. **On resolution (APPROVED or REJECTED):** The cron job's isolated session will announce the result to the user's last active channel. After announcing, remove the cron job: ```bash openclaw cron remove <jobId> ``` The bot should also update the local cache at `~/.telnyx/upgrade.json` and, if APPROVED, retry the original blocked operation. **Cleanup:** If the cron job runs 24 times (24 hours) with no resolution, it should self-disable and notify the user to contact Telnyx support directly: > Your upgrade application has been under review for over 24 hours. Please contact Telnyx support directly: https://support.telnyx.com ### TIMEOUT / POLLING FAILURE > The upgrade evaluation is taking longer than expected. Your evaluation ID is: <EVALUATION_ID> > > I'll check again later. Continuing with freemium features for now. Cache the `evaluation_id` for later polling. ### ERROR (API failure) > The upgrade evaluation encountered an error. This may be temporary — I'll retry shortly. Retry up to 3 times with exponential backoff (1s, 2s, 4s). If still failing, report the error and suggest the user try again later. ### ALREADY ATTEMPTED Check `used_methods` to determine which methods remain: - **`github_oauth` used, `linkedin_oauth` not used** → > A GitHub-based upgrade evaluation was already submitted. Each verification method can only be used once. I can try LinkedIn verification instead — this requires opening a link in your browser. Want me to proceed? - **`linkedin_oauth` used, `github_oauth` not used** → > A LinkedIn-based upgrade evaluation was already submitted. Each verification method can only be used once. I can try GitHub verification instead. Want me to proceed? - **Both used** → > Both verification methods (GitHub and LinkedIn) have been used for this account. Please contact Telnyx support: https://support.telnyx.com --- ## Error Handling Reference | # | Scenario | Detection | Handling | |---|----------|-----------|---------| | 1 | `gh` not installed | `which gh` fails | Report: "GitHub CLI (gh) is required. Install: https://cli.github.com". Alternatively, offer manual PAT creation via `github.com/settings/tokens`. | | 2 | `gh` not authenticated | `gh auth status` fails | Run `gh auth login --web --scopes user,read:org` and guide user through browser flow | | 3 | `ghs_` token (CI/CD) | Token prefix = `ghs_` | ABORT with clear message (see Token Type Detection above). Cannot verify human identity. Must `gh auth login --web`. | | 4 | `github_pat_` token | Token prefix = `github_pat_` | WARN about degraded data, proceed anyway. Cannot refresh scopes on PATs. | | 5 | API key invalid/expired | 401 from gateway | Report to user, suggest checking API key | | 6 | Already upgraded (prior APPROVED) | Cache or status endpoint check | Skip evaluation, proceed with original blocked operation | | 7 | Attempt limit reached (429) | `used_methods` includes the method | Report that the method was already used. If the other method is available, suggest it. If both used, suggest support. | | 8 | Network errors | HTTP 0 / connection failure | Retry up to 3 times with exponential backoff (1s, 2s, 4s). If persistent, notify user. | | 9 | Evaluation already in progress (409) | Backend returns 409 | Check cached `evaluation_id`, resume polling instead of submitting new evaluation | | 10 | PASS_TO_HUMAN from previous session | Cache shows PASS_TO_HUMAN | Re-poll the cached `evaluation_id` silently. Notify user if decision changed. | | 11 | User doesn't complete device code in time | `gh auth refresh` exits with error after 15 min | Generate new code, re-notify user via their active channel. Max 3 retries before giving up. | | 12 | User on Telegram, away from machine | User's last channel = telegram | Send device code + verification URL to Telegram chat (see templates above) | | 13 | User on Slack, away from machine | User's last channel = slack | Send device code via Slack DM using `slack-send.sh` (see templates above) | | 14 | User on phone, NOT logged into GitHub | N/A — extra login step on phone | Device code link still works; user will see GitHub login before device page. One extra tap. | | 15 | User completely offline | No channel responds to notification | Save state to `~/.telnyx/upgrade.json` with `pending_action: "scope_refresh"`. Set up heartbeat polling (every 30 min). On user return, first message = upgrade prompt. | | 16 | Device code expires (15 min TTL) | `gh auth refresh` exits with error | Generate new code, re-notify user. Max 3 retries before giving up. | | 17 | Multiple bots, same user | Backend returns 409/429 | Handle gracefully — check status endpoint, resume polling existing evaluation | | 18 | GitHub outage | API calls to GitHub fail with HTTP 5xx | Detect via error response. Notify user. Retry later via heartbeat. | | 19 | User cancels authorization | `gh auth refresh` returns `access_denied` | Inform user: "Authorization was cancelled. Run again when ready." | | 20 | Concurrent scope refresh + evaluation | Race condition | `gh auth refresh` is blocking — evaluation only starts after it returns. No race condition. |

path: scripts/check-gh-auth.sh
content: #!/usr/bin/env bash
# check-gh-auth.sh — Check GitHub CLI authentication status, token type, and scopes.
# Outputs JSON with compatibility assessment for Telnyx upgrade evaluation.
#
# Usage: bash check-gh-auth.sh
# Output: JSON to stdout

set -euo pipefail

REQUIRED_SCOPES=("user" "read:org")

json_output() {
    local authenticated="$1"
    local username="$2"
    local token_prefix="$3"
    local token_type="$4"
    local scopes="$5"
    local missing_scopes="$6"
    local compatible="$7"
    local degraded="$8"
    local message="$9"

    cat <<EOF
{
  "authenticated": ${authenticated},
  "username": ${username},
  "token_prefix": ${token_prefix},
  "token_type": ${token_type},
  "scopes": ${scopes},
  "missing_scopes": ${missing_scopes},
  "compatible": ${compatible},
  "degraded": ${degraded},
  "message": ${message}
}
EOF
}

# Check if gh is installed
if ! command -v gh &>/dev/null; then
    json_output "false" "null" "null" "null" "[]" "[]" "false" "false" \
        '"GitHub CLI (gh) is not installed. Install: https://cli.github.com"'
    exit 0
fi

# Check if gh is authenticated
if ! gh auth status &>/dev/null 2>&1; then
    json_output "false" "null" "null" "null" "[]" "[]" "false" "false" \
        '"GitHub CLI is not authenticated. Run: gh auth login --web"'
    exit 0
fi

# Get the token
TOKEN=$(gh auth token 2>/dev/null || true)
if [ -z "$TOKEN" ]; then
    json_output "false" "null" "null" "null" "[]" "[]" "false" "false" \
        '"Failed to retrieve GitHub token. Run: gh auth login --web"'
    exit 0
fi

# Determine token prefix and type
TOKEN_PREFIX="${TOKEN:0:4}"
TOKEN_TYPE=""
COMPATIBLE=true
DEGRADED=false

case "$TOKEN" in
    gho_*)
        TOKEN_PREFIX="gho_"
        TOKEN_TYPE="oauth"
        ;;
    ghp_*)
        TOKEN_PREFIX="ghp_"
        TOKEN_TYPE="classic_pat"
        ;;
    github_pat_*)
        TOKEN_PREFIX="github_pat_"
        TOKEN_TYPE="fine_grained_pat"
        DEGRADED=true
        ;;
    ghs_*)
        TOKEN_PREFIX="ghs_"
        TOKEN_TYPE="app_installation"
        COMPATIBLE=false
        ;;
    ghu_*)
        TOKEN_PREFIX="ghu_"
        TOKEN_TYPE="app_user"
        ;;
    *)
        TOKEN_PREFIX="${TOKEN:0:4}_"
        TOKEN_TYPE="unknown"
        ;;
esac

# Get username from gh auth status
USERNAME=$(gh auth status 2>&1 | grep -oP 'Logged in to github\.com account \K\S+' || \
           gh auth status 2>&1 | grep -oP 'account \K\S+' || \
           echo "")
# Remove trailing parenthesis or special chars
USERNAME=$(echo "$USERNAME" | sed 's/[^a-zA-Z0-9_.-]//g')

if [ -z "$USERNAME" ]; then
    USERNAME="null"
else
    USERNAME="\"${USERNAME}\""
fi

# Parse scopes from gh auth status
SCOPES_RAW=$(gh auth status 2>&1 | grep -i "token scopes" | sed "s/.*Token scopes: *'//;s/'//g" || echo "")
# Also try the format without quotes
if [ -z "$SCOPES_RAW" ]; then
    SCOPES_RAW=$(gh auth status 2>&1 | grep -i "scopes" | sed "s/.*scopes: *//;s/'//g" || echo "")
fi

# Build scopes JSON array
SCOPES_JSON="["
MISSING_JSON="["
if [ -n "$SCOPES_RAW" ]; then
    IFS=',' read -ra SCOPE_ARR <<< "$SCOPES_RAW"
    first=true
    for scope in "${SCOPE_ARR[@]}"; do
        scope=$(echo "$scope" | xargs)  # trim whitespace
        if [ -n "$scope" ]; then
            if [ "$first" = true ]; then
                SCOPES_JSON+="\"${scope}\""
                first=false
            else
                SCOPES_JSON+=", \"${scope}\""
            fi
        fi
    done
fi
SCOPES_JSON+="]"

# Check for missing required scopes
first=true
for req in "${REQUIRED_SCOPES[@]}"; do
    found=false
    if [ -n "$SCOPES_RAW" ]; then
        IFS=',' read -ra SCOPE_ARR <<< "$SCOPES_RAW"
        for scope in "${SCOPE_ARR[@]}"; do
            scope=$(echo "$scope" | xargs)
            if [ "$scope" = "$req" ]; then
                found=true
                break
            fi
        done
    fi
    if [ "$found" = false ]; then
        if [ "$first" = true ]; then
            MISSING_JSON+="\"${req}\""
            first=false
        else
            MISSING_JSON+=", \"${req}\""
        fi
    fi
done
MISSING_JSON+="]"

# Build message
if [ "$COMPATIBLE" = false ]; then
    MESSAGE="\"Incompatible token type: ${TOKEN_TYPE}. GitHub App installation tokens cannot access user profile data. Run: gh auth login --web\""
elif [ "$DEGRADED" = true ]; then
    MESSAGE="\"Fine-grained PAT detected. Some profile data (organizations, GraphQL contributions) may be unavailable. For best results, run: gh auth login --web\""
elif [ "$MISSING_JSON" != "[]" ]; then
    MESSAGE="\"Missing required scopes. Run: gh auth refresh --scopes user,read:org\""
else
    MESSAGE="\"GitHub CLI is authenticated and compatible. Ready for upgrade evaluation.\""
fi

json_output "true" "$USERNAME" "\"${TOKEN_PREFIX}\"" "\"${TOKEN_TYPE}\"" \
    "$SCOPES_JSON" "$MISSING_JSON" "$COMPATIBLE" "$DEGRADED" "$MESSAGE"


path: scripts/evaluate-upgrade.py
content: #!/usr/bin/env python3
"""evaluate-upgrade.py — Telnyx professional upgrade evaluation via GitHub or LinkedIn.

Supports two verification methods:
  --github-token <token>  Submit GitHub token directly (automated, no browser)
  --linkedin              Start LinkedIn OAuth flow (requires browser)

Polls for results, caches the outcome, and outputs structured JSON.

Also supports --poll-only to re-check an existing evaluation by ID
(used by cron jobs for PASS_TO_HUMAN follow-up).

Uses Python stdlib only (no third-party dependencies).

Usage:
    # GitHub: submit token + poll:
    python3 evaluate-upgrade.py --github-token <token> --api-key <telnyx_api_key>

    # LinkedIn: start OAuth flow + poll:
    python3 evaluate-upgrade.py --linkedin --api-key <telnyx_api_key>

    # Poll-only (re-check existing evaluation):
    python3 evaluate-upgrade.py --poll-only --evaluation-id <id> --api-key <telnyx_api_key>
"""

import argparse
import json
import os
from pathlib import Path
import sys
import time
import urllib.error
import urllib.request

DEFAULT_API_BASE = "https://api.telnyx.com"
DEFAULT_TIMEOUT = 120
DEFAULT_POLL_INTERVAL = 5
CACHE_PATH = Path.home() / ".telnyx" / "upgrade.json"
TELNYX_CONFIG_PATH = Path.home() / ".config" / "telnyx" / "config.json"
MAX_RETRIES = 3
RETRY_BACKOFF = [1, 2, 4]


def resolve_api_key(cli_value):
    """Resolve Telnyx API key: CLI arg > env var > ~/.config/telnyx/config.json."""
    if cli_value:
        return cli_value
    env_key = os.environ.get("TELNYX_API_KEY")
    if env_key:
        return env_key
    if TELNYX_CONFIG_PATH.exists():
        try:
            with open(TELNYX_CONFIG_PATH) as f:
                config = json.load(f)
            key = config.get("api_key", "")
            if key:
                return key
        except (json.JSONDecodeError, OSError):
            pass
    return None


def make_request(url, method="GET", data=None, headers=None, timeout=30):
    """Make an HTTP request using urllib. Returns (status_code, response_dict)."""
    if headers is None:
        headers = {}

    body = None
    if data is not None:
        body = json.dumps(data).encode("utf-8")
        headers["Content-Type"] = "application/json"

    req = urllib.request.Request(
        url, data=body, headers=headers, method=method
    )

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            resp_body = resp.read().decode("utf-8")
            return resp.status, json.loads(resp_body) if resp_body else {}
    except urllib.error.HTTPError as e:
        resp_body = e.read().decode("utf-8") if e.fp else ""
        try:
            resp_data = json.loads(resp_body) if resp_body else {}
        except json.JSONDecodeError:
            resp_data = {"detail": resp_body}
        return e.code, resp_data
    except urllib.error.URLError as e:
        return 0, {"detail": str(e.reason)}


def make_request_with_retry(
    url, method="GET", data=None, headers=None, timeout=30
):
    """Make an HTTP request with retry and exponential backoff."""
    last_status = 0
    last_resp = {}

    for attempt in range(MAX_RETRIES):
        status, resp = make_request(url, method, data, headers, timeout)

        # Success or client error (don't retry 4xx)
        if 200 <= status < 500:
            return status, resp

        last_status = status
        last_resp = resp

        if attempt < MAX_RETRIES - 1:
            backoff = (
                RETRY_BACKOFF[attempt] if attempt < len(RETRY_BACKOFF) else 4
            )
            time.sleep(backoff)

    return last_status, last_resp


def submit_evaluation(api_base, api_key, github_token):
    """Submit GitHub token for evaluation. Returns (success, data)."""
    url = f"{api_base}/v2/account/upgrade/github"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }
    data = {"github_token": github_token}

    status, resp = make_request_with_retry(
        url, method="POST", data=data, headers=headers
    )

    if status == 201:
        return True, resp
    else:
        return False, {
            "status_code": status,
            "detail": resp.get("detail", resp.get("message", str(resp))),
        }


def submit_linkedin_evaluation(api_base, api_key):
    """Start LinkedIn OAuth flow for evaluation. Returns (success, data)."""
    url = f"{api_base}/v2/account/upgrade/linkedin"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }

    status, resp = make_request_with_retry(
        url, method="POST", headers=headers
    )

    if status == 200:
        return True, resp
    else:
        return False, {
            "status_code": status,
            "detail": resp.get("detail", resp.get("message", str(resp))),
        }


def poll_latest_evaluation(
    api_base, api_key, user_id, timeout, poll_interval
):
    """Poll latest evaluation for a user until a linkedin_oauth evaluation appears.

    Returns (evaluation_id, status_resp) or (None, error_resp) on timeout.
    """
    url = f"{api_base}/v2/account/user/{user_id}/latest-upgrade-request"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }

    start = time.time()

    while time.time() - start < timeout:
        status, resp = make_request(url, headers=headers)

        if status == 200 and resp:
            # Check if this is a linkedin_oauth evaluation that's being processed
            used_methods = resp.get("used_methods", [])
            eval_status = resp.get("status", "")
            evaluation_id = resp.get("evaluation_id")
            if (
                evaluation_id
                and "linkedin_oauth" in used_methods
                and eval_status in ("pending", "processing", "completed", "failed")
            ):
                return evaluation_id, resp

        time.sleep(poll_interval)

    return None, {"status": "polling_timeout"}


def poll_status(api_base, api_key, evaluation_id, timeout, poll_interval):
    """Poll for evaluation status. Returns the final status response."""
    url = f"{api_base}/v2/account/upgrade-request-status/{evaluation_id}"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }

    start = time.time()

    while time.time() - start < timeout:
        status, resp = make_request(url, headers=headers)

        if status != 200:
            # Transient error — keep polling
            time.sleep(poll_interval)
            continue

        eval_status = resp.get("status", "")

        if eval_status in ("completed", "failed"):
            return resp

        time.sleep(poll_interval)

    # Timed out
    return {
        "status": "polling_timeout",
        "request_id": evaluation_id,
    }


def save_cache(data):
    """Save evaluation result to local cache."""
    CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
    with open(CACHE_PATH, "w") as f:
        json.dump(data, f, indent=2)


def build_output(evaluation_id, status_resp):
    """Build the structured output from a status response."""
    status = status_resp.get("status", "unknown")
    decision = status_resp.get("decision")
    used_methods = status_resp.get("used_methods", [])

    # Build contextual message and next_steps
    if status == "completed" and decision == "APPROVED":
        message = "Professional upgrade approved. Account upgraded to professional tier."
        next_steps = "Retry the blocked operation. You may need to refresh your API key."
    elif status == "completed" and decision == "REJECTED":
        message = "Professional upgrade not approved."
        github_used = "github_oauth" in used_methods
        linkedin_used = "linkedin_oauth" in used_methods
        if github_used and not linkedin_used:
            next_steps = (
                "Try LinkedIn verification: "
                "python3 evaluate-upgrade.py --linkedin"
            )
        elif linkedin_used and not github_used:
            next_steps = (
                "Try GitHub verification: "
                "python3 evaluate-upgrade.py --github-token <TOKEN>"
            )
        elif github_used and linkedin_used:
            next_steps = (
                "All verification methods have been used. "
                "Contact support at https://support.telnyx.com"
            )
        else:
            next_steps = (
                "Try GitHub verification or LinkedIn verification."
            )
    elif status == "completed" and decision == "PASS_TO_HUMAN":
        message = "Upgrade application is under manual review."
        next_steps = "A support ticket has been created. Check back later."
    elif status == "failed":
        message = "Evaluation failed due to an error."
        next_steps = (
            "This may be temporary. Try again later or contact support."
        )
    elif status == "polling_timeout":
        message = (
            "Evaluation is still processing. Timed out waiting for result."
        )
        next_steps = (
            "The evaluation is still running server-side. Check back later."
        )
    else:
        message = f"Evaluation status: {status}"
        next_steps = "Continue polling for updates."

    return {
        "evaluation_id": evaluation_id,
        "status": status,
        "decision": decision,
        "used_methods": used_methods,
        "message": message,
        "next_steps": next_steps,
    }


def main():
    parser = argparse.ArgumentParser(
        description="Telnyx professional upgrade evaluation via GitHub or LinkedIn"
    )
    method_group = parser.add_mutually_exclusive_group()
    method_group.add_argument(
        "--github-token",
        help="GitHub OAuth access token (submit directly)",
    )
    method_group.add_argument(
        "--linkedin",
        action="store_true",
        help="Start LinkedIn OAuth flow (requires browser)",
    )
    method_group.add_argument(
        "--poll-only",
        action="store_true",
        help="Skip submission, only poll an existing evaluation",
    )
    parser.add_argument(
        "--api-key",
        default=None,
        help="Telnyx API key (also reads TELNYX_API_KEY env or ~/.config/telnyx/config.json)",
    )
    parser.add_argument(
        "--api-base",
        default=os.environ.get("TELNYX_API_BASE", DEFAULT_API_BASE),
        help=f"Telnyx API base URL (default: {DEFAULT_API_BASE})",
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=int(os.environ.get("UPGRADE_POLL_TIMEOUT", DEFAULT_TIMEOUT)),
        help=f"Max seconds to wait for evaluation (default: {DEFAULT_TIMEOUT})",
    )
    parser.add_argument(
        "--poll-interval",
        type=int,
        default=int(
            os.environ.get("UPGRADE_POLL_INTERVAL", DEFAULT_POLL_INTERVAL)
        ),
        help=f"Seconds between status polls (default: {DEFAULT_POLL_INTERVAL})",
    )
    parser.add_argument(
        "--evaluation-id",
        help="Evaluation ID to poll (required with --poll-only)",
    )
    parser.add_argument(
        "--user-id",
        help="Telnyx user ID (for LinkedIn flow polling; normally returned by endpoint)",
    )
    args = parser.parse_args()

    # Resolve API key from CLI arg, env var, or config file
    [REDACTED_SECRET])
    if not [REDACTED_SECRET] = {
            "evaluation_id": None,
            "status": "failed",
            "decision": None,
            "used_methods": [],
            "message": "No Telnyx API key found. Set TELNYX_API_KEY env var, pass --api-key, or run 'telnyx auth setup'.",
            "next_steps": "Configure a Telnyx API key.",
        }
        print(json.dumps(output, indent=2))
        sys.exit(1)
    args.[REDACTED_SECRET]

    # Poll-only mode: skip submission, just check status
    if args.poll_only:
        evaluation_id = args.evaluation_id
        if not evaluation_id:
            # Try reading from cache
            if CACHE_PATH.exists():
                with open(CACHE_PATH) as f:
                    cached = json.load(f)
                evaluation_id = cached.get("evaluation_id")
            if not evaluation_id:
                output = {
                    "evaluation_id": None,
                    "status": "failed",
                    "decision": None,
                    "used_methods": [],
                    "message": "No evaluation_id provided and none found in cache.",
                    "next_steps": "Submit a new evaluation or provide --evaluation-id.",
                }
                print(json.dumps(output, indent=2))
                sys.exit(1)

        # Single status check (no long polling loop)
        url = f"{args.api_base}/v2/account/upgrade-request-status/{evaluation_id}"
        headers = {
            "Authorization": f"Bearer {args.api_key}",
            "Accept": "application/json",
        }
        status_code, status_resp = make_request_with_retry(
            url, headers=headers
        )

        if status_code != 200:
            output = {
                "evaluation_id": evaluation_id,
                "status": "failed",
                "decision": None,
                "used_methods": [],
                "message": f"Status check failed ({status_code}): {status_resp.get('detail', '')}",
                "next_steps": "Retry later.",
            }
            print(json.dumps(output, indent=2))
            sys.exit(1)

        output = build_output(evaluation_id, status_resp)
        if output["status"] != "failed":
            cache_data = {
                "evaluation_id": evaluation_id,
                "status": output["status"],
                "decision": output["decision"],
                "used_methods": output["used_methods"],
                "timestamp": time.strftime(
                    "%Y-%m-%dT%H:%M:%SZ", time.gmtime()
                ),
            }
            save_cache(cache_data)

        print(json.dumps(output, indent=2))
        sys.exit(0 if output.get("decision") == "APPROVED" else 1)

    # LinkedIn flow: start OAuth → present URL → poll for evaluation
    if args.linkedin:
        # Step 1: Start LinkedIn OAuth flow
        success, submit_resp = submit_linkedin_evaluation(
            args.api_base, args.api_key
        )

        if not success:
            status_code = submit_resp.get("status_code", 0)
            detail = submit_resp.get("detail", "Unknown error")
            output = {
                "evaluation_id": None,
                "status": "failed",
                "decision": None,
                "used_methods": [],
                "message": f"LinkedIn submission failed ({status_code}): {detail}",
                "next_steps": "Check your API key, then try again.",
            }
            print(json.dumps(output, indent=2))
            sys.exit(1)

        authorization_url = submit_resp.get("authorization_url", "")
        user_id = args.user_id or submit_resp.get("user_id", "")

        if not authorization_url:
            output = {
                "evaluation_id": None,
                "status": "failed",
                "decision": None,
                "used_methods": [],
                "message": "No authorization URL returned.",
                "next_steps": "Contact support.",
            }
            print(json.dumps(output, indent=2))
            sys.exit(1)

        # Step 2: Output the URL for the bot to present to the user (stderr
        # to keep stdout reserved for the single final JSON result)
        url_output = {
            "action": "open_url",
            "url": authorization_url,
            "message": submit_resp.get("message", "Open this URL to verify with LinkedIn."),
        }
        print(json.dumps(url_output, indent=2), file=sys.stderr)

        if not user_id:
            output = {
                "evaluation_id": None,
                "status": "failed",
                "decision": None,
                "used_methods": [],
                "message": "No user_id available for polling. Pass --user-id.",
                "next_steps": "Provide --user-id and try again.",
            }
            print(json.dumps(output, indent=2))
            sys.exit(1)

        # Step 3: Poll for the linkedin_oauth evaluation to appear
        poll_start = time.time()
        evaluation_id, latest_resp = poll_latest_evaluation(
            args.api_base,
            args.api_key,
            user_id,
            args.timeout,
            args.poll_interval,
        )

        if not evaluation_id:
            output = {
                "evaluation_id": None,
                "status": "polling_timeout",
                "decision": None,
                "used_methods": [],
                "message": (
                    "Timed out waiting for LinkedIn verification to complete. "
                    "The user may not have opened the URL yet."
                ),
                "next_steps": (
                    "The authorization URL is still valid for 10 minutes. "
                    "Once the user completes OAuth, re-run with --poll-only."
                ),
            }
            print(json.dumps(output, indent=2))
            sys.exit(1)

        # Step 4: If evaluation already completed, use that; otherwise poll status
        # Deduct time spent waiting for the evaluation to appear
        elapsed = time.time() - poll_start
        remaining_timeout = max(10, args.timeout - int(elapsed))
        eval_status = latest_resp.get("status", "")
        if eval_status in ("completed", "failed"):
            status_resp = latest_resp
        else:
            status_resp = poll_status(
                args.api_base,
                args.api_key,
                evaluation_id,
                remaining_timeout,
                args.poll_interval,
            )

        # Step 5: Build output, cache, print
        output = build_output(evaluation_id, status_resp)

        if output["status"] != "failed":
            cache_data = {
                "evaluation_id": evaluation_id,
                "status": output["status"],
                "decision": output["decision"],
                "used_methods": output["used_methods"],
                "timestamp": time.strftime(
                    "%Y-%m-%dT%H:%M:%SZ", time.gmtime()
                ),
            }
            save_cache(cache_data)

        print(json.dumps(output, indent=2))
        sys.exit(0 if output.get("decision") == "APPROVED" else 1)

    # GitHub flow: submit token + poll
    if not args.github_token:
        parser.error(
            "one of --github-token, --linkedin, or --poll-only is required"
        )

    # Step 1: Submit evaluation
    success, submit_resp = submit_evaluation(
        args.api_base, args.api_key, args.github_token
    )

    if not success:
        status_code = submit_resp.get("status_code", 0)
        detail = submit_resp.get("detail", "Unknown error")
        output = {
            "evaluation_id": None,
            "status": "failed",
            "decision": None,
            "used_methods": [],
            "message": f"Submission failed ({status_code}): {detail}",
            "next_steps": "Check your API key and GitHub token, then try again.",
        }
        print(json.dumps(output, indent=2))
        sys.exit(1)

    evaluation_id = submit_resp.get("evaluation_id", "")
    if not evaluation_id:
        output = {
            "evaluation_id": None,
            "status": "failed",
            "decision": None,
            "used_methods": [],
            "message": "Submission succeeded but no evaluation_id returned.",
            "next_steps": "Contact support.",
        }
        print(json.dumps(output, indent=2))
        sys.exit(1)

    # Step 2: Poll for result
    status_resp = poll_status(
        args.api_base,
        args.api_key,
        evaluation_id,
        args.timeout,
        args.poll_interval,
    )

    # Step 3: Build output
    output = build_output(evaluation_id, status_resp)

    # Step 4: Cache result (skip caching failures to allow retry)
    if output["status"] != "failed":
        cache_data = {
            "evaluation_id": evaluation_id,
            "status": output["status"],
            "decision": output["decision"],
            "used_methods": output["used_methods"],
            "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        }
        save_cache(cache_data)

    # Step 5: Output result
    print(json.dumps(output, indent=2))

    # Exit with non-zero if not approved
    if output.get("decision") != "APPROVED":
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/get-gh-token.sh
content: #!/usr/bin/env bash
# get-gh-token.sh — Extract the current GitHub OAuth token via gh CLI.
# Validates the token is non-empty before outputting.
#
# Usage: bash get-gh-token.sh
# Output: Raw GitHub token on stdout (single line)

set -euo pipefail

# Check if gh is installed
if ! command -v gh &>/dev/null; then
    echo "ERROR: GitHub CLI (gh) is not installed. Install: https://cli.github.com" >&2
    exit 1
fi

# Check if gh is authenticated
if ! gh auth status &>/dev/null 2>&1; then
    echo "ERROR: GitHub CLI is not authenticated. Run: gh auth login --web" >&2
    exit 1
fi

# Get the token
TOKEN=$(gh auth token 2>/dev/null)

if [ -z "$TOKEN" ]; then
    echo "ERROR: Failed to retrieve GitHub token. Run: gh auth login --web" >&2
    exit 1
fi

echo "$TOKEN"


path: scripts/refresh-gh-scopes.sh
content: #!/usr/bin/env bash
# refresh-gh-scopes.sh — Refresh GitHub CLI scopes to include user and read:org.
# Returns the device code for the user to authorize manually or via cross-channel notification.
#
# Usage: bash refresh-gh-scopes.sh
# Output: JSON to stdout

set -euo pipefail

PID_FILE="${HOME}/.telnyx/gh_refresh.pid"

json_output() {
    local success="$1"
    local message="$2"
    local requires_browser="$3"
    local device_code="$4"
    local verification_uri="$5"
    local pid="${6:-null}"

    cat <<EOF
{
  "success": ${success},
  "message": ${message},
  "requires_browser": ${requires_browser},
  "device_code": ${device_code},
  "verification_uri": ${verification_uri},
  "pid": ${pid}
}
EOF
}

# Check if gh is installed
if ! command -v gh &>/dev/null; then
    json_output "false" '"GitHub CLI (gh) is not installed. Install: https://cli.github.com"' \
        "false" "null" "null"
    exit 0
fi

# Check if gh is authenticated
if ! gh auth status &>/dev/null 2>&1; then
    json_output "false" '"GitHub CLI is not authenticated. Run: gh auth login --web"' \
        "false" "null" "null"
    exit 0
fi

# Run gh auth refresh with browser suppressed to capture device code.
# gh auth refresh writes the device code to stderr and blocks until the
# user authorizes or the code expires (15 minutes).
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT

# Start refresh in background with BROWSER=false to force device code flow
BROWSER=false gh auth refresh --scopes user,read:org 2>"$TMPFILE" &
REFRESH_PID=$!

# Wait briefly for gh to output the device code
sleep 3

# Check if the process already exited (success without needing browser)
if ! kill -0 "$REFRESH_PID" 2>/dev/null; then
    wait "$REFRESH_PID" && REFRESH_EXIT=0 || REFRESH_EXIT=$?
    if [ "$REFRESH_EXIT" -eq 0 ]; then
        json_output "true" '"Scopes refreshed successfully. No browser interaction needed."' \
            "false" "null" "null"
        exit 0
    else
        ERROR_MSG=$(cat "$TMPFILE" | tr '\n' ' ' | sed 's/"/\\"/g')
        json_output "false" "\"Scope refresh failed: ${ERROR_MSG}\"" \
            "false" "null" "null"
        exit 0
    fi
fi

# Process is still running — parse device code from stderr output
STDERR_OUTPUT=$(cat "$TMPFILE")

DEVICE_CODE=$(echo "$STDERR_OUTPUT" | grep -oE '[A-Z0-9]{4}-[A-Z0-9]{4}' | head -1 || echo "")
VERIFICATION_URI=$(echo "$STDERR_OUTPUT" | grep -oP 'https://github\.com/login/device' | head -1 || echo "https://github.com/login/device")

if [ -z "$DEVICE_CODE" ]; then
    # Could not parse device code — unexpected output
    ERROR_MSG=$(echo "$STDERR_OUTPUT" | tr '\n' ' ' | sed 's/"/\\"/g')
    kill "$REFRESH_PID" 2>/dev/null || true
    json_output "false" "\"Unexpected output from gh auth refresh: ${ERROR_MSG}\"" \
        "false" "null" "null"
    exit 0
fi

# Device code found — save PID so wait-for-auth.sh can track it.
mkdir -p "$(dirname "$PID_FILE")"
echo "$REFRESH_PID" > "$PID_FILE"

# Return device code + PID for the bot to track.
# The gh auth refresh process polls GitHub every 5s for up to 15 minutes.
# Use wait-for-auth.sh to block until auth completes or expires.
json_output "false" \
    "\"Scope refresh requires browser authorization. Enter the device code at the verification URL, then run wait-for-auth.sh to confirm.\"" \
    "true" "\"${DEVICE_CODE}\"" "\"${VERIFICATION_URI}\"" "$REFRESH_PID"


path: scripts/wait-for-auth.sh
content: #!/usr/bin/env bash
# wait-for-auth.sh — Wait for the gh auth refresh background process to complete.
#
# Called after refresh-gh-scopes.sh returns a device code and the user has
# authorized it. Blocks until the gh process exits or times out.
#
# Usage: bash wait-for-auth.sh [--timeout <seconds>] [--pid <pid>]
# Output: JSON to stdout

set -euo pipefail

PID_FILE="${HOME}/.telnyx/gh_refresh.pid"
DEFAULT_TIMEOUT=900  # 15 minutes (matches device code TTL)

TIMEOUT="$DEFAULT_TIMEOUT"
PID=""

while [[ $# -gt 0 ]]; do
    case "$1" in
        --timeout) TIMEOUT="$2"; shift 2 ;;
        --pid) PID="$2"; shift 2 ;;
        *) shift ;;
    esac
done

# Resolve PID from argument or file
if [ -z "$PID" ]; then
    if [ -f "$PID_FILE" ]; then
        PID=$(cat "$PID_FILE")
    else
        cat <<EOF
{
  "success": false,
  "message": "No gh auth refresh process found. Run refresh-gh-scopes.sh first."
}
EOF
        exit 1
    fi
fi

# Check if process is still running
if ! kill -0 "$PID" 2>/dev/null; then
    # Already exited — check if it succeeded by verifying scopes
    if gh auth status 2>&1 | grep -q "user"; then
        rm -f "$PID_FILE"
        cat <<EOF
{
  "success": true,
  "message": "Scopes refreshed successfully."
}
EOF
        exit 0
    else
        rm -f "$PID_FILE"
        cat <<EOF
{
  "success": false,
  "message": "gh auth refresh process exited but scopes were not updated."
}
EOF
        exit 1
    fi
fi

# Process is running — wait for it with timeout
START=$(date +%s)
while kill -0 "$PID" 2>/dev/null; do
    ELAPSED=$(( $(date +%s) - START ))
    if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
        kill "$PID" 2>/dev/null || true
        rm -f "$PID_FILE"
        cat <<EOF
{
  "success": false,
  "message": "Timed out after ${TIMEOUT}s waiting for authorization. Device code may have expired."
}
EOF
        exit 1
    fi
    sleep 2
done

# Process exited — check result
wait "$PID" 2>/dev/null && EXIT_CODE=0 || EXIT_CODE=$?
rm -f "$PID_FILE"

if [ "$EXIT_CODE" -eq 0 ]; then
    cat <<EOF
{
  "success": true,
  "message": "Scopes refreshed successfully. Authorization complete."
}
EOF
    exit 0
else
    cat <<EOF
{
  "success": false,
  "message": "gh auth refresh failed (exit code ${EXIT_CODE}). The device code may have expired or authorization was denied."
}
EOF
    exit 1
fi


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)