CtrlK
BlogDocsLog inGet started
Tessl Logo

odyssey4me/github

Create and manage GitHub issues, pull requests, workflows, and repositories using the gh CLI. Use when asked to open a PR, merge a pull request, check repo actions, list issues, create a branch, or manage GitHub projects.

94

Quality

94%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Skills
Evals
Files

github.pyscripts/

#!/usr/bin/env python3
"""GitHub wrapper skill for AI agents.

Wraps the gh CLI to produce markdown-formatted output for read/view commands.
Action commands (create, merge, close, comment) should use gh directly.

Usage:
    python github.py check
    python github.py issues list --repo OWNER/REPO
    python github.py issues view 123 --repo OWNER/REPO
    python github.py prs list --repo OWNER/REPO
    python github.py prs view 456 --repo OWNER/REPO
    python github.py prs checks 456 --repo OWNER/REPO
    python github.py prs status --repo OWNER/REPO
    python github.py runs list --repo OWNER/REPO
    python github.py runs view 123456 --repo OWNER/REPO
    python github.py repos list
    python github.py repos view OWNER/REPO
    python github.py search repos "machine learning"
    python github.py search issues "label:bug is:open"
    python github.py search prs "is:open review:required"

Requirements:
    gh CLI (https://cli.github.com/)
"""

from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
from typing import Any

# ============================================================================
# CONSTANTS — JSON field lists per entity type
# ============================================================================

ISSUE_LIST_FIELDS = "number,title,state,author,assignees,labels,createdAt,updatedAt"
ISSUE_VIEW_FIELDS = (
    "number,title,state,body,author,assignees,labels,milestone,createdAt,updatedAt,comments,url"
)
PR_LIST_FIELDS = "number,title,state,author,assignees,labels,createdAt,updatedAt,isDraft"
PR_VIEW_FIELDS = (
    "number,title,state,body,author,assignees,labels,milestone,"
    "createdAt,updatedAt,comments,url,isDraft,mergeable,reviewDecision,"
    "additions,deletions,changedFiles,headRefName,baseRefName"
)
PR_STATUS_FIELDS = "number,title,state,headRefName,baseRefName,isDraft,reviewDecision,author,url"
PR_CHECKS_FIELDS = "name,status,conclusion,startedAt,completedAt,detailsUrl"
RUN_LIST_FIELDS = "databaseId,displayTitle,status,conclusion,event,createdAt,updatedAt,url"
RUN_VIEW_FIELDS = (
    "databaseId,displayTitle,status,conclusion,event,"
    "createdAt,updatedAt,url,workflowName,headBranch,headSha,jobs"
)
REPO_LIST_FIELDS = "name,owner,description,isPrivate,isFork,stargazerCount,updatedAt,url"
REPO_VIEW_FIELDS = (
    "name,owner,description,isPrivate,isFork,stargazerCount,"
    "forkCount,watchers,updatedAt,url,defaultBranchRef,homepageUrl,"
    "primaryLanguage,licenseInfo,isArchived"
)
SEARCH_REPOS_FIELDS = "fullName,description,isPrivate,stargazersCount,updatedAt,url"
SEARCH_ISSUES_FIELDS = (
    "repository,number,title,state,author,assignees,labels,createdAt,updatedAt,url"
)
SEARCH_PRS_FIELDS = (
    "repository,number,title,state,author,assignees,labels,createdAt,updatedAt,url,isDraft"
)


# ============================================================================
# gh CLI HELPER
# ============================================================================


def run_gh(args: list[str], json_fields: str | None = None) -> dict[str, Any] | list[Any] | str:
    """Run a gh CLI command and return parsed output.

    Args:
        args: Arguments to pass to gh (e.g., ["issue", "list"]).
        json_fields: Comma-separated field list for --json output.

    Returns:
        Parsed JSON data (dict or list), or raw string output.

    Raises:
        SystemExit: If gh command fails.
    """
    cmd = ["gh", *args]
    if json_fields:
        cmd.extend(["--json", json_fields])

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"Error: {result.stderr.strip()}", file=sys.stderr)
        sys.exit(1)

    output = result.stdout.strip()
    if json_fields and output:
        return json.loads(output)
    return output


# ============================================================================
# DATE FORMATTING
# ============================================================================


def format_date(iso_date: str | None) -> str:
    """Format ISO 8601 date to YYYY-MM-DD HH:MM.

    Args:
        iso_date: ISO 8601 date string (e.g., "2024-01-15T10:30:00Z").

    Returns:
        Formatted date string, or "N/A" if input is None/empty.
    """
    if not iso_date:
        return "N/A"
    # ISO 8601: 2024-01-15T10:30:00Z → 2024-01-15 10:30
    return iso_date[:10] + " " + iso_date[11:16] if len(iso_date) >= 16 else iso_date[:10]


# ============================================================================
# FORMAT FUNCTIONS — one per entity type (markdown output)
# ============================================================================


def format_issue_summary(issue: dict[str, Any]) -> str:
    """Format a GitHub issue for markdown display.

    Args:
        issue: Issue dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    number = issue.get("number", "?")
    title = issue.get("title", "(No title)")
    state = issue.get("state", "UNKNOWN")
    author = _get_login(issue.get("author"))
    assignees = _get_logins(issue.get("assignees", []))
    labels = _get_label_names(issue.get("labels", []))
    created = format_date(issue.get("createdAt"))

    lines = [
        f"### #{number}: {title}",
        f"- **State:** {state}",
        f"- **Author:** {author}",
    ]
    if assignees:
        lines.append(f"- **Assignees:** {assignees}")
    if labels:
        lines.append(f"- **Labels:** {labels}")
    lines.append(f"- **Created:** {created}")

    body = issue.get("body")
    if body:
        lines.append(f"\n{body.strip()}")

    url = issue.get("url")
    if url:
        lines.append(f"\n- **URL:** {url}")

    return "\n".join(lines)


def format_issue_row(issue: dict[str, Any]) -> str:
    """Format a GitHub issue as a compact markdown entry for lists.

    Args:
        issue: Issue dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    number = issue.get("number", "?")
    title = issue.get("title", "(No title)")
    state = issue.get("state", "UNKNOWN")
    author = _get_login(issue.get("author"))
    labels = _get_label_names(issue.get("labels", []))
    created = format_date(issue.get("createdAt"))

    lines = [
        f"### #{number}: {title}",
        f"- **State:** {state}",
        f"- **Author:** {author}",
    ]
    if labels:
        lines.append(f"- **Labels:** {labels}")
    lines.append(f"- **Created:** {created}")
    return "\n".join(lines)


def format_pr_summary(pr: dict[str, Any]) -> str:
    """Format a GitHub pull request for markdown display.

    Args:
        pr: PR dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    number = pr.get("number", "?")
    title = pr.get("title", "(No title)")
    state = pr.get("state", "UNKNOWN")
    draft = " (Draft)" if pr.get("isDraft") else ""
    author = _get_login(pr.get("author"))
    assignees = _get_logins(pr.get("assignees", []))
    labels = _get_label_names(pr.get("labels", []))
    created = format_date(pr.get("createdAt"))

    lines = [
        f"### #{number}: {title}{draft}",
        f"- **State:** {state}",
        f"- **Author:** {author}",
    ]
    if assignees:
        lines.append(f"- **Assignees:** {assignees}")
    if labels:
        lines.append(f"- **Labels:** {labels}")

    head = pr.get("headRefName")
    base = pr.get("baseRefName")
    if head and base:
        lines.append(f"- **Branch:** {head} → {base}")

    review = pr.get("reviewDecision")
    if review:
        lines.append(f"- **Review:** {review}")

    additions = pr.get("additions")
    deletions = pr.get("deletions")
    changed = pr.get("changedFiles")
    if additions is not None:
        lines.append(f"- **Changes:** +{additions} -{deletions} ({changed} files)")

    lines.append(f"- **Created:** {created}")

    body = pr.get("body")
    if body:
        lines.append(f"\n{body.strip()}")

    url = pr.get("url")
    if url:
        lines.append(f"\n- **URL:** {url}")

    return "\n".join(lines)


def format_pr_row(pr: dict[str, Any]) -> str:
    """Format a GitHub PR as a compact markdown entry for lists.

    Args:
        pr: PR dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    number = pr.get("number", "?")
    title = pr.get("title", "(No title)")
    state = pr.get("state", "UNKNOWN")
    draft = " (Draft)" if pr.get("isDraft") else ""
    author = _get_login(pr.get("author"))
    labels = _get_label_names(pr.get("labels", []))
    created = format_date(pr.get("createdAt"))

    lines = [
        f"### #{number}: {title}{draft}",
        f"- **State:** {state}",
        f"- **Author:** {author}",
    ]
    if labels:
        lines.append(f"- **Labels:** {labels}")
    lines.append(f"- **Created:** {created}")
    return "\n".join(lines)


def format_pr_status(data: dict[str, Any]) -> str:
    """Format gh pr status output as markdown.

    Args:
        data: Dictionary with 'currentBranch', 'createdBy', 'needsReview' keys.

    Returns:
        Markdown-formatted string.
    """
    lines = ["## PR Status"]

    current = data.get("currentBranch")
    lines.append("\n### Current Branch")
    if current:
        pr = current
        number = pr.get("number", "?")
        title = pr.get("title", "(No title)")
        state = pr.get("state", "UNKNOWN")
        draft = " (Draft)" if pr.get("isDraft") else ""
        review = pr.get("reviewDecision", "")
        url = pr.get("url", "")
        lines.append(f"- **#{number}: {title}**{draft}")
        line_parts = [f"  {state}"]
        if review:
            line_parts.append(f"Review: {review}")
        lines.append(" | ".join(line_parts))
        if url:
            lines.append(f"  {url}")
    else:
        lines.append("No PR for current branch")

    for section_key, section_title in [
        ("createdBy", "Created by You"),
        ("needsReview", "Requesting Your Review"),
    ]:
        prs = data.get(section_key, [])
        lines.append(f"\n### {section_title}")
        if not prs:
            lines.append("None")
        else:
            for pr in prs:
                number = pr.get("number", "?")
                title = pr.get("title", "(No title)")
                state = pr.get("state", "UNKNOWN")
                draft = " (Draft)" if pr.get("isDraft") else ""
                head = pr.get("headRefName", "")
                base = pr.get("baseRefName", "")
                review = pr.get("reviewDecision", "")
                branch = f" ({head} → {base})" if head and base else ""
                entry = f"- **#{number}: {title}**{draft}{branch}"
                parts = [f"  {state}"]
                if review:
                    parts.append(f"Review: {review}")
                lines.append(entry)
                lines.append(" | ".join(parts))

    return "\n".join(lines)


def format_check(check: dict[str, Any]) -> str:
    """Format a PR check/status for markdown display.

    Args:
        check: Check dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    name = check.get("name", "(Unknown)")
    status = check.get("status", "UNKNOWN")
    conclusion = check.get("conclusion", "")
    started = format_date(check.get("startedAt"))

    state = conclusion if conclusion else status
    return f"- **{name}:** {state} (started {started})"


def format_run_summary(run: dict[str, Any]) -> str:
    """Format a GitHub Actions run for markdown display.

    Args:
        run: Run dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    run_id = run.get("databaseId", "?")
    title = run.get("displayTitle", "(No title)")
    status = run.get("status", "UNKNOWN")
    conclusion = run.get("conclusion", "")
    event = run.get("event", "")
    created = format_date(run.get("createdAt"))

    state = f"{status}" if not conclusion else f"{conclusion}"

    lines = [
        f"### {title} (#{run_id})",
        f"- **Status:** {state}",
    ]
    if event:
        lines.append(f"- **Event:** {event}")
    lines.append(f"- **Created:** {created}")

    workflow = run.get("workflowName")
    if workflow:
        lines.append(f"- **Workflow:** {workflow}")

    branch = run.get("headBranch")
    if branch:
        lines.append(f"- **Branch:** {branch}")

    sha = run.get("headSha")
    if sha:
        lines.append(f"- **Commit:** {sha[:8]}")

    url = run.get("url")
    if url:
        lines.append(f"- **URL:** {url}")

    jobs = run.get("jobs", [])
    if jobs:
        lines.append("\n**Jobs:**")
        for job in jobs:
            job_name = job.get("name", "?")
            job_status = job.get("conclusion") or job.get("status", "?")
            lines.append(f"- **{job_name}:** {job_status}")

    return "\n".join(lines)


def format_run_row(run: dict[str, Any]) -> str:
    """Format a GitHub Actions run as a compact markdown entry for lists.

    Args:
        run: Run dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    run_id = run.get("databaseId", "?")
    title = run.get("displayTitle", "(No title)")
    status = run.get("status", "UNKNOWN")
    conclusion = run.get("conclusion", "")
    event = run.get("event", "")
    created = format_date(run.get("createdAt"))

    state = f"{status}" if not conclusion else f"{conclusion}"

    lines = [
        f"### {title} (#{run_id})",
        f"- **Status:** {state}",
    ]
    if event:
        lines.append(f"- **Event:** {event}")
    lines.append(f"- **Created:** {created}")
    return "\n".join(lines)


def format_repo_summary(repo: dict[str, Any]) -> str:
    """Format a GitHub repository for markdown display.

    Args:
        repo: Repository dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    owner = repo.get("owner", {})
    owner_login = owner.get("login", "") if isinstance(owner, dict) else str(owner)
    name = repo.get("name", "(Unknown)")
    full_name = f"{owner_login}/{name}" if owner_login else name
    description = repo.get("description") or "(No description)"
    private = "Private" if repo.get("isPrivate") else "Public"
    stars = repo.get("stargazerCount", 0)
    forks = repo.get("forkCount", 0)
    updated = format_date(repo.get("updatedAt"))

    lines = [
        f"### {full_name}",
        f"- **Description:** {description}",
        f"- **Visibility:** {private}",
        f"- **Stars:** {stars}",
    ]

    if forks:
        lines.append(f"- **Forks:** {forks}")

    lang = repo.get("primaryLanguage")
    if lang:
        lang_name = lang.get("name", "") if isinstance(lang, dict) else str(lang)
        if lang_name:
            lines.append(f"- **Language:** {lang_name}")

    license_info = repo.get("licenseInfo")
    if license_info:
        lic_name = license_info.get("name", "") if isinstance(license_info, dict) else ""
        if lic_name:
            lines.append(f"- **License:** {lic_name}")

    default_branch = repo.get("defaultBranchRef")
    if default_branch:
        branch_name = default_branch.get("name", "") if isinstance(default_branch, dict) else ""
        if branch_name:
            lines.append(f"- **Default Branch:** {branch_name}")

    lines.append(f"- **Updated:** {updated}")

    url = repo.get("url")
    if url:
        lines.append(f"- **URL:** {url}")

    return "\n".join(lines)


def format_repo_row(repo: dict[str, Any]) -> str:
    """Format a GitHub repository as a compact markdown entry for lists.

    Args:
        repo: Repository dictionary from gh --json output.

    Returns:
        Markdown-formatted string.
    """
    owner = repo.get("owner", {})
    owner_login = owner.get("login", "") if isinstance(owner, dict) else str(owner)
    name = repo.get("name", "(Unknown)")
    full_name = f"{owner_login}/{name}" if owner_login else name
    description = repo.get("description") or "(No description)"
    private = "Private" if repo.get("isPrivate") else "Public"
    stars = repo.get("stargazerCount", 0)

    lines = [
        f"### {full_name}",
        f"- **Description:** {description}",
        f"- **Visibility:** {private}",
        f"- **Stars:** {stars}",
    ]
    return "\n".join(lines)


def format_search_repo(repo: dict[str, Any]) -> str:
    """Format a search result repository for markdown display.

    Args:
        repo: Search result dictionary from gh search --json output.

    Returns:
        Markdown-formatted string.
    """
    full_name = repo.get("fullName", "(Unknown)")
    description = repo.get("description") or "(No description)"
    private = "Private" if repo.get("isPrivate") else "Public"
    stars = repo.get("stargazersCount", 0)
    updated = format_date(repo.get("updatedAt"))

    lines = [
        f"### {full_name}",
        f"- **Description:** {description}",
        f"- **Visibility:** {private}",
        f"- **Stars:** {stars}",
        f"- **Updated:** {updated}",
    ]

    url = repo.get("url")
    if url:
        lines.append(f"- **URL:** {url}")

    return "\n".join(lines)


def format_search_issue(issue: dict[str, Any]) -> str:
    """Format a search result issue for markdown display.

    Args:
        issue: Search result dictionary from gh search --json output.

    Returns:
        Markdown-formatted string.
    """
    repo = issue.get("repository", {})
    repo_name = repo.get("nameWithOwner", "") if isinstance(repo, dict) else str(repo)
    number = issue.get("number", "?")
    title = issue.get("title", "(No title)")
    state = issue.get("state", "UNKNOWN")
    author = _get_login(issue.get("author"))
    labels = _get_label_names(issue.get("labels", []))
    created = format_date(issue.get("createdAt"))

    prefix = f"{repo_name}#" if repo_name else "#"
    lines = [
        f"### {prefix}{number}: {title}",
        f"- **State:** {state}",
        f"- **Author:** {author}",
    ]
    if labels:
        lines.append(f"- **Labels:** {labels}")
    lines.append(f"- **Created:** {created}")
    return "\n".join(lines)


# ============================================================================
# HELPER FUNCTIONS
# ============================================================================


def _get_login(author: dict[str, Any] | None) -> str:
    """Extract login name from an author/user dict.

    Args:
        author: User dictionary with 'login' key.

    Returns:
        Login string or "Unknown".
    """
    if not author:
        return "Unknown"
    if isinstance(author, dict):
        return author.get("login", "Unknown")
    return str(author)


def _get_logins(users: list[dict[str, Any]]) -> str:
    """Extract comma-separated login names from a list of user dicts.

    Args:
        users: List of user dictionaries.

    Returns:
        Comma-separated login names, or empty string if none.
    """
    if not users:
        return ""
    names = []
    for u in users:
        if isinstance(u, dict):
            names.append(u.get("login", "?"))
        else:
            names.append(str(u))
    return ", ".join(names)


def _get_label_names(labels: list[dict[str, Any]]) -> str:
    """Extract comma-separated label names from a list of label dicts.

    Args:
        labels: List of label dictionaries.

    Returns:
        Comma-separated label names, or empty string if none.
    """
    if not labels:
        return ""
    names = []
    for label in labels:
        if isinstance(label, dict):
            names.append(label.get("name", "?"))
        else:
            names.append(str(label))
    return ", ".join(names)


# ============================================================================
# COMMAND HANDLERS — one per subcommand, return exit code
# ============================================================================


def cmd_check(_args: argparse.Namespace) -> int:
    """Verify gh CLI is installed and authenticated.

    Args:
        _args: Parsed arguments (unused).

    Returns:
        Exit code (0 success, 1 error).
    """
    if not shutil.which("gh"):
        print("Error: gh CLI not found. Install from https://cli.github.com/", file=sys.stderr)
        return 1

    result = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
    if result.returncode != 0:
        print("Error: gh CLI not authenticated.", file=sys.stderr)
        print(result.stderr.strip(), file=sys.stderr)
        print("\nRun: gh auth login", file=sys.stderr)
        return 1

    print("✓ gh CLI is installed and authenticated")
    # Show auth details (stderr from gh auth status contains the info)
    for line in result.stderr.strip().splitlines():
        print(f"  {line.strip()}")
    return 0


def cmd_issues_list(args: argparse.Namespace) -> int:
    """List issues for a repository.

    Args:
        args: Parsed arguments with repo, limit, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["issue", "list"]
    if args.repo:
        gh_args.extend(["-R", args.repo])
    gh_args.extend(["--limit", str(args.limit)])

    if args.json:
        data = run_gh(gh_args, ISSUE_LIST_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, ISSUE_LIST_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No issues found")
        else:
            print(f"## Issues\n\nFound {len(items)} issue(s):\n")
            print("\n\n".join(format_issue_row(i) for i in items))
    return 0


def cmd_issues_view(args: argparse.Namespace) -> int:
    """View a single issue.

    Args:
        args: Parsed arguments with issue number, repo, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["issue", "view", str(args.number)]
    if args.repo:
        gh_args.extend(["-R", args.repo])

    if args.json:
        data = run_gh(gh_args, ISSUE_VIEW_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, ISSUE_VIEW_FIELDS)
        if isinstance(data, dict):
            print(format_issue_summary(data))
    return 0


def cmd_prs_list(args: argparse.Namespace) -> int:
    """List pull requests for a repository.

    Args:
        args: Parsed arguments with repo, limit, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["pr", "list"]
    if args.repo:
        gh_args.extend(["-R", args.repo])
    gh_args.extend(["--limit", str(args.limit)])

    if args.json:
        data = run_gh(gh_args, PR_LIST_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, PR_LIST_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No pull requests found")
        else:
            print(f"## Pull Requests\n\nFound {len(items)} PR(s):\n")
            print("\n\n".join(format_pr_row(pr) for pr in items))
    return 0


def cmd_prs_view(args: argparse.Namespace) -> int:
    """View a single pull request.

    Args:
        args: Parsed arguments with PR number, repo, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["pr", "view", str(args.number)]
    if args.repo:
        gh_args.extend(["-R", args.repo])

    if args.json:
        data = run_gh(gh_args, PR_VIEW_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, PR_VIEW_FIELDS)
        if isinstance(data, dict):
            print(format_pr_summary(data))
    return 0


def cmd_prs_checks(args: argparse.Namespace) -> int:
    """View CI checks for a pull request.

    Args:
        args: Parsed arguments with PR number, repo, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["pr", "checks", str(args.number)]
    if args.repo:
        gh_args.extend(["-R", args.repo])

    if args.json:
        data = run_gh(gh_args, PR_CHECKS_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, PR_CHECKS_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No checks found")
        else:
            print(f"## PR #{args.number} Checks\n")
            print("\n".join(format_check(c) for c in items))
    return 0


def cmd_prs_status(args: argparse.Namespace) -> int:
    """Show status of relevant pull requests.

    Args:
        args: Parsed arguments with repo, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["pr", "status"]
    if args.repo:
        gh_args.extend(["-R", args.repo])

    if args.json:
        data = run_gh(gh_args, PR_STATUS_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, PR_STATUS_FIELDS)
        if isinstance(data, dict):
            print(format_pr_status(data))
    return 0


def cmd_runs_list(args: argparse.Namespace) -> int:
    """List workflow runs for a repository.

    Args:
        args: Parsed arguments with repo, limit, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["run", "list"]
    if args.repo:
        gh_args.extend(["-R", args.repo])
    gh_args.extend(["--limit", str(args.limit)])

    if args.json:
        data = run_gh(gh_args, RUN_LIST_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, RUN_LIST_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No workflow runs found")
        else:
            print(f"## Workflow Runs\n\nFound {len(items)} run(s):\n")
            print("\n\n".join(format_run_row(r) for r in items))
    return 0


def cmd_runs_view(args: argparse.Namespace) -> int:
    """View a single workflow run.

    Args:
        args: Parsed arguments with run ID, repo, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["run", "view", str(args.run_id)]
    if args.repo:
        gh_args.extend(["-R", args.repo])

    if args.json:
        data = run_gh(gh_args, RUN_VIEW_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, RUN_VIEW_FIELDS)
        if isinstance(data, dict):
            print(format_run_summary(data))
    return 0


def cmd_repos_list(args: argparse.Namespace) -> int:
    """List repositories for the authenticated user.

    Args:
        args: Parsed arguments with limit, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["repo", "list"]
    gh_args.extend(["--limit", str(args.limit)])

    if args.json:
        data = run_gh(gh_args, REPO_LIST_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, REPO_LIST_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No repositories found")
        else:
            print(f"## Repositories\n\nFound {len(items)} repository(ies):\n")
            print("\n\n".join(format_repo_row(r) for r in items))
    return 0


def cmd_repos_view(args: argparse.Namespace) -> int:
    """View a single repository.

    Args:
        args: Parsed arguments with repo name, json flag.

    Returns:
        Exit code.
    """
    gh_args = ["repo", "view", args.repo]

    if args.json:
        data = run_gh(gh_args, REPO_VIEW_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, REPO_VIEW_FIELDS)
        if isinstance(data, dict):
            print(format_repo_summary(data))
    return 0


def cmd_search_repos(args: argparse.Namespace) -> int:
    """Search repositories.

    Args:
        args: Parsed arguments with query, limit, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["search", "repos", args.query, "--limit", str(args.limit)]

    if args.json:
        data = run_gh(gh_args, SEARCH_REPOS_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, SEARCH_REPOS_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No repositories found")
        else:
            print(f"## Search Results\n\nFound {len(items)} repository(ies):\n")
            print("\n\n".join(format_search_repo(r) for r in items))
    return 0


def cmd_search_issues(args: argparse.Namespace) -> int:
    """Search issues.

    Args:
        args: Parsed arguments with query, limit, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["search", "issues", args.query, "--limit", str(args.limit)]

    if args.json:
        data = run_gh(gh_args, SEARCH_ISSUES_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, SEARCH_ISSUES_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No issues found")
        else:
            print(f"## Search Results\n\nFound {len(items)} issue(s):\n")
            print("\n\n".join(format_search_issue(i) for i in items))
    return 0


def cmd_search_prs(args: argparse.Namespace) -> int:
    """Search pull requests.

    Args:
        args: Parsed arguments with query, limit, json flags.

    Returns:
        Exit code.
    """
    gh_args = ["search", "prs", args.query, "--limit", str(args.limit)]

    if args.json:
        data = run_gh(gh_args, SEARCH_PRS_FIELDS)
        print(json.dumps(data, indent=2))
    else:
        data = run_gh(gh_args, SEARCH_PRS_FIELDS)
        items = data if isinstance(data, list) else []
        if not items:
            print("No pull requests found")
        else:
            print(f"## Search Results\n\nFound {len(items)} PR(s):\n")
            print("\n\n".join(format_search_issue(i) for i in items))
    return 0


# ============================================================================
# ARGUMENT PARSER
# ============================================================================


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser with nested subcommands.

    Returns:
        Configured ArgumentParser.
    """
    parser = argparse.ArgumentParser(
        description="GitHub wrapper for AI agents — markdown-formatted gh output",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    subparsers = parser.add_subparsers(dest="command", help="Command to execute")

    # check
    subparsers.add_parser("check", help="Verify gh CLI is installed and authenticated")

    # issues
    issues_parser = subparsers.add_parser("issues", help="Issue operations")
    issues_sub = issues_parser.add_subparsers(dest="issues_command")

    issues_list = issues_sub.add_parser("list", help="List issues")
    issues_list.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    issues_list.add_argument("--limit", type=int, default=30, help="Max results (default 30)")
    issues_list.add_argument("--json", action="store_true", help="Output raw JSON")

    issues_view = issues_sub.add_parser("view", help="View issue details")
    issues_view.add_argument("number", type=int, help="Issue number")
    issues_view.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    issues_view.add_argument("--json", action="store_true", help="Output raw JSON")

    # prs
    prs_parser = subparsers.add_parser("prs", help="Pull request operations")
    prs_sub = prs_parser.add_subparsers(dest="prs_command")

    prs_list = prs_sub.add_parser("list", help="List pull requests")
    prs_list.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    prs_list.add_argument("--limit", type=int, default=30, help="Max results (default 30)")
    prs_list.add_argument("--json", action="store_true", help="Output raw JSON")

    prs_view = prs_sub.add_parser("view", help="View PR details")
    prs_view.add_argument("number", type=int, help="PR number")
    prs_view.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    prs_view.add_argument("--json", action="store_true", help="Output raw JSON")

    prs_checks = prs_sub.add_parser("checks", help="View PR checks")
    prs_checks.add_argument("number", type=int, help="PR number")
    prs_checks.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    prs_checks.add_argument("--json", action="store_true", help="Output raw JSON")

    prs_status = prs_sub.add_parser("status", help="Show status of relevant PRs")
    prs_status.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    prs_status.add_argument("--json", action="store_true", help="Output raw JSON")

    # runs
    runs_parser = subparsers.add_parser("runs", help="Workflow run operations")
    runs_sub = runs_parser.add_subparsers(dest="runs_command")

    runs_list = runs_sub.add_parser("list", help="List workflow runs")
    runs_list.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    runs_list.add_argument("--limit", type=int, default=30, help="Max results (default 30)")
    runs_list.add_argument("--json", action="store_true", help="Output raw JSON")

    runs_view = runs_sub.add_parser("view", help="View workflow run details")
    runs_view.add_argument("run_id", type=int, help="Run ID")
    runs_view.add_argument("--repo", "-R", help="Repository (OWNER/REPO)")
    runs_view.add_argument("--json", action="store_true", help="Output raw JSON")

    # repos
    repos_parser = subparsers.add_parser("repos", help="Repository operations")
    repos_sub = repos_parser.add_subparsers(dest="repos_command")

    repos_list = repos_sub.add_parser("list", help="List repositories")
    repos_list.add_argument("--limit", type=int, default=30, help="Max results (default 30)")
    repos_list.add_argument("--json", action="store_true", help="Output raw JSON")

    repos_view = repos_sub.add_parser("view", help="View repository details")
    repos_view.add_argument("repo", help="Repository (OWNER/REPO)")
    repos_view.add_argument("--json", action="store_true", help="Output raw JSON")

    # search
    search_parser = subparsers.add_parser("search", help="Search operations")
    search_sub = search_parser.add_subparsers(dest="search_command")

    search_repos = search_sub.add_parser("repos", help="Search repositories")
    search_repos.add_argument("query", help="Search query")
    search_repos.add_argument("--limit", type=int, default=30, help="Max results (default 30)")
    search_repos.add_argument("--json", action="store_true", help="Output raw JSON")

    search_issues = search_sub.add_parser("issues", help="Search issues")
    search_issues.add_argument("query", help="Search query")
    search_issues.add_argument("--limit", type=int, default=30, help="Max results (default 30)")
    search_issues.add_argument("--json", action="store_true", help="Output raw JSON")

    search_prs = search_sub.add_parser("prs", help="Search pull requests")
    search_prs.add_argument("query", help="Search query")
    search_prs.add_argument("--limit", type=int, default=30, help="Max results (default 30)")
    search_prs.add_argument("--json", action="store_true", help="Output raw JSON")

    return parser


# ============================================================================
# MAIN
# ============================================================================


def main() -> int:
    """Main entry point.

    Returns:
        Exit code.
    """
    parser = build_parser()
    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 1

    if args.command == "check":
        return cmd_check(args)
    elif args.command == "issues":
        if not hasattr(args, "issues_command") or not args.issues_command:
            parser.parse_args(["issues", "--help"])
            return 1
        if args.issues_command == "list":
            return cmd_issues_list(args)
        elif args.issues_command == "view":
            return cmd_issues_view(args)
    elif args.command == "prs":
        if not hasattr(args, "prs_command") or not args.prs_command:
            parser.parse_args(["prs", "--help"])
            return 1
        if args.prs_command == "list":
            return cmd_prs_list(args)
        elif args.prs_command == "view":
            return cmd_prs_view(args)
        elif args.prs_command == "checks":
            return cmd_prs_checks(args)
        elif args.prs_command == "status":
            return cmd_prs_status(args)
    elif args.command == "runs":
        if not hasattr(args, "runs_command") or not args.runs_command:
            parser.parse_args(["runs", "--help"])
            return 1
        if args.runs_command == "list":
            return cmd_runs_list(args)
        elif args.runs_command == "view":
            return cmd_runs_view(args)
    elif args.command == "repos":
        if not hasattr(args, "repos_command") or not args.repos_command:
            parser.parse_args(["repos", "--help"])
            return 1
        if args.repos_command == "list":
            return cmd_repos_list(args)
        elif args.repos_command == "view":
            return cmd_repos_view(args)
    elif args.command == "search":
        if not hasattr(args, "search_command") or not args.search_command:
            parser.parse_args(["search", "--help"])
            return 1
        if args.search_command == "repos":
            return cmd_search_repos(args)
        elif args.search_command == "issues":
            return cmd_search_issues(args)
        elif args.search_command == "prs":
            return cmd_search_prs(args)

    parser.print_help()
    return 1


if __name__ == "__main__":
    sys.exit(main())

Install with Tessl CLI

npx tessl i odyssey4me/github

scripts

SKILL.md

tile.json