Skip to content

gh — Integration Guide

Generated: 2026-05-07 CLI version: 2.88.1 Scope: Critical failure modes

Invocation Invariants

These constraints must hold on every call to gh, regardless of language or framework:

binary:  gh
stdin:   closed (DEVNULL / equivalent)
timeout: 30s
env:     GH_PROMPT_DISABLED=1    # §10 — disables all interactive prompts
         GH_PAGER=cat            # §10 — suppresses pager
         GH_NO_UPDATE_NOTIFIER=1 # §41 — suppresses update notices on stderr
         NO_COLOR=1              # §8  — suppresses ANSI color codes
         GH_TOKEN=<token>        # §45, §53 — pre-set auth; never rely on stored creds
flags:   --json <fields>         # §2  — force JSON output (not auto-activated)

Critical: gh exits 0 on HTTP errors (§1, §53). Never branch on exit code alone. Always inspect stderr for error patterns (see Per-Failure-Mode Workarounds below).


Per-Failure-Mode Workarounds (score < 3, sorted: severity desc, score asc)

§1 — Exit Codes & Status Signaling [Critical · 0/3]

Gap: HTTP 4xx and GraphQL errors exit 0. Exit code is not a reliable success signal.

Workaround:

import subprocess, json

result = subprocess.run(
    ["gh", *your_args, "--json", fields],
    env=env, stdin=subprocess.DEVNULL,
    capture_output=True, text=True, timeout=30
)

# Never trust exit code alone — always inspect stderr
if result.returncode != 0:
    # returncode 1 = genuine gh error
    raise GhError(result.stderr)

# Check stderr for error patterns even on exit 0
stderr = result.stderr.strip()
if "HTTP 401" in stderr or "Bad credentials" in stderr:
    raise AuthError(stderr)
if "HTTP 404" in stderr or "Could not resolve" in stderr:
    raise NotFoundError(stderr)
if "HTTP" in stderr:
    raise GhError(stderr)

# Only now trust the stdout
data = json.loads(result.stdout)

§53 — Credential Expiry Mid-Session [Critical · 0/3]

Gap: Expired/invalid GH_TOKENHTTP 401: Bad credentials on stderr, exits 0. No structured error. Recovery suggestion (gh auth login) is interactive-only.

Workaround:

AUTH_ERROR_PATTERNS = [
    "HTTP 401",
    "Bad credentials",
    "Try authenticating with:",
    "HTTP 403",
    "Must have admin rights",
]

def check_stderr_for_auth_error(stderr: str) -> None:
    for pattern in AUTH_ERROR_PATTERNS:
        if pattern in stderr:
            raise AuthExpiredError(
                f"gh auth failure detected in stderr. "
                f"Refresh GH_TOKEN and retry. Raw: {stderr[:200]}"
            )

# On AuthExpiredError: replace GH_TOKEN from secret store and retry once

§12 — Idempotency & Safe Retries [Critical · 1/3]

Gap: No idempotency key support, no effect field, no --dry-run. Retrying a failed create produces duplicates.

Workaround — query before mutate:

def safe_create_issue(repo: str, title: str, body: str) -> dict:
    # 1. Check if issue already exists before creating
    result = subprocess.run(
        ["gh", "issue", "list", "--repo", repo,
         "--search", f'"{title}" in:title', "--json", "number,title"],
        env=env, stdin=subprocess.DEVNULL,
        capture_output=True, text=True, timeout=30
    )
    existing = json.loads(result.stdout)
    for issue in existing:
        if issue["title"] == title:
            return {"effect": "noop", "number": issue["number"]}

    # 2. Only create if not found
    result = subprocess.run(
        ["gh", "issue", "create", "--repo", repo,
         "--title", title, "--body", body],
        env=env, stdin=subprocess.DEVNULL,
        capture_output=True, text=True, timeout=30
    )
    check_stderr_for_auth_error(result.stderr)
    url = result.stdout.strip()
    number = int(url.rstrip("/").split("/")[-1])
    return {"effect": "created", "number": number, "url": url}

§45 — Headless Authentication [Critical · 1/3]

Gap: No structured auth error. No non-interactive re-auth path. gh auth login requires browser.

Workaround — pre-flight auth check:

def verify_auth(token: str) -> bool:
    result = subprocess.run(
        ["gh", "api", "user", "--jq", ".login"],
        env={**os.environ, "GH_TOKEN": token, "NO_COLOR": "1"},
        stdin=subprocess.DEVNULL,
        capture_output=True, text=True, timeout=10
    )
    return result.returncode == 0 and "HTTP 401" not in result.stderr

# Run verify_auth() before any workflow that depends on gh auth
# On failure: replace GH_TOKEN from secret store; do not call gh auth login

§2 — Output Format & Parseability [Critical · 2/3]

Gap: --json not auto-activated; requires explicit field list per command; no response envelope.

Workaround — always specify --json and normalize the response:

def gh_json(args: list[str], fields: str, env: dict) -> dict:
    result = subprocess.run(
        ["gh", *args, "--json", fields],
        env=env, stdin=subprocess.DEVNULL,
        capture_output=True, text=True, timeout=30
    )
    check_stderr_for_auth_error(result.stderr)
    if result.returncode != 0:
        raise GhError(result.stderr or result.stdout)
    return json.loads(result.stdout)

# Usage:
data = gh_json(
    ["issue", "list", "--repo", "owner/repo", "--limit", "50"],
    fields="number,title,state,createdAt",
    env=BASE_ENV,
)

§10 — Interactivity & TTY Requirements [Critical · 2/3]

Gap: No --non-interactive flag; suppression requires env vars.

Workaround: Always include in BASE_ENV (already in Invocation Invariants above). Additionally set:

BASE_ENV = {
    **os.environ,
    "GH_PROMPT_DISABLED": "1",
    "GH_PAGER": "cat",
    "NO_COLOR": "1",
    "GH_NO_UPDATE_NOTIFIER": "1",
    "GH_TOKEN": os.environ["GH_TOKEN"],   # must be pre-set
}

§11 — Timeouts & Hanging Processes [Critical · 2/3]

Gap: No --timeout flag. Use subprocess timeout on macOS (no GNU timeout).

Workaround:

try:
    result = subprocess.run(cmd, env=env, stdin=subprocess.DEVNULL,
                            capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
    raise GhTimeoutError(f"gh timed out after 30s: {cmd}")

§43 — Output Size Unboundedness [Critical · 2/3]

Gap: No pagination metadata in JSON output — agents cannot detect truncation.

Workaround — always paginate explicitly:

def gh_list_all(resource_args: list[str], fields: str, page_size: int = 100) -> list:
    """Fetch all pages explicitly rather than relying on default limit."""
    results = []
    page = 1
    while True:
        data = gh_json(
            [*resource_args, "--limit", str(page_size)],
            fields=fields, env=BASE_ENV
        )
        if not data:
            break
        results.extend(data if isinstance(data, list) else [data])
        if len(data) < page_size:
            break   # last page
        page += 1
    return results

§62 — $EDITOR and $VISUAL Trap [Critical · 2/3]

Gap: gh issue create without --body opens $EDITOR. No EDITOR_REQUIRED error in non-TTY.

Workaround — always supply all content flags; set EDITOR to no-op:

BASE_ENV = {
    **BASE_ENV,
    "GH_EDITOR": "/bin/true",   # no-op if editor accidentally invoked
    "EDITOR": "/bin/true",
    "VISUAL": "/bin/true",
}

# Always pass --title AND --body (or --body-file) to create commands
result = subprocess.run(
    ["gh", "issue", "create", "--repo", repo,
     "--title", title,
     "--body", body],          # never omit --body
    env=BASE_ENV, stdin=subprocess.DEVNULL,
    capture_output=True, text=True, timeout=30
)

No Action Needed

§8 ANSI & Color Code Leakage, §50 Stdin Consumption Deadlock, §60 OS Output Buffer Deadlock, §64 Headless Display and GUI Launch Blocking (score 3/3)