recon-github 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/cli.py ADDED
@@ -0,0 +1,13 @@
1
+ import typer
2
+
3
+ from app.commands import auth, me, repo
4
+
5
+ app = typer.Typer()
6
+
7
+ # Commands that can be ran in the CLI
8
+ app.command(name="login")(auth.login)
9
+ app.command(name="logout")(auth.logout)
10
+ app.command(name="me")(me.me)
11
+ app.command(name="scout")(me.scout)
12
+ app.command(name="list")(repo.list)
13
+ app.command(name="details")(repo.details)
app/commands/auth.py ADDED
@@ -0,0 +1,17 @@
1
+ import typer
2
+
3
+ from app.services import auth
4
+
5
+ app = typer.Typer()
6
+
7
+
8
+ @app.command()
9
+ def login():
10
+ """Login to GitHub using the device flow."""
11
+ auth.login()
12
+
13
+
14
+ @app.command()
15
+ def logout():
16
+ """Logout of GitHub."""
17
+ auth.logout()
app/commands/me.py ADDED
@@ -0,0 +1,23 @@
1
+ import typer
2
+
3
+ from app.ui.display import display_user
4
+
5
+ app = typer.Typer()
6
+
7
+
8
+ @app.command()
9
+ def me():
10
+ """Get information about me!"""
11
+ from app.services.github import get_authenticated_user
12
+
13
+ user = get_authenticated_user()
14
+ display_user(user)
15
+
16
+
17
+ @app.command()
18
+ def scout(user: str):
19
+ """Scout another GitHub user."""
20
+ from app.services.github import get_user
21
+
22
+ profile = get_user(user)
23
+ display_user(profile)
app/commands/repo.py ADDED
@@ -0,0 +1,202 @@
1
+ from builtins import list as builtins_list
2
+ from typing import cast
3
+
4
+ import typer
5
+
6
+ app = typer.Typer()
7
+
8
+ units = ["KB", "MB", "GB", "TB", "PB"]
9
+
10
+
11
+ def format_topics(topics: list[str], max_topics: int = 5) -> str:
12
+ shown = topics[:max_topics]
13
+ remaining = len(topics) - len(shown)
14
+ rendered = ", ".join(shown)
15
+ if remaining > 0:
16
+ rendered = f"{rendered} + {remaining} more..."
17
+ return rendered
18
+
19
+
20
+ def format_description(description: str, max_chars: int = 90) -> str:
21
+ if len(description) <= max_chars:
22
+ return description
23
+
24
+ # Truncate at or after max_chars, but never in the middle of a word.
25
+ cutoff = description.find(" ", max_chars)
26
+ if cutoff == -1:
27
+ cutoff = max_chars
28
+
29
+ shown = description[:cutoff].rstrip()
30
+ return f"{shown}..."
31
+
32
+
33
+ def format_size(size: int) -> str:
34
+ units = ["KB", "MB", "GB", "TB", "PB"]
35
+
36
+ current_unit = 0
37
+ scaled_size: float = size
38
+
39
+ while scaled_size >= 1000 and current_unit < len(units) - 1:
40
+ scaled_size = scaled_size / 1000
41
+ current_unit += 1
42
+
43
+ return f"{scaled_size:.1f} {units[current_unit]}"
44
+
45
+
46
+ def format_languages(languages: dict[str, int], max_languages: int = 5) -> list[str]:
47
+ if not languages:
48
+ return ["No language data returned."]
49
+
50
+ total_bytes = sum(languages.values())
51
+
52
+ sorted_languages = sorted(languages.items(), key=lambda item: item[1], reverse=True)
53
+
54
+ shown = sorted_languages[:max_languages]
55
+ remaining = len(sorted_languages) - len(shown)
56
+
57
+ formatted = []
58
+
59
+ for language, byte_count in shown:
60
+ percent = (byte_count / total_bytes * 100) if total_bytes else 0
61
+ formatted.append(f"{language}: {percent:.1f}%")
62
+
63
+ if remaining > 0:
64
+ formatted.append(f"+ {remaining} more...")
65
+
66
+ return formatted
67
+
68
+
69
+ @app.command()
70
+ def list():
71
+ """See a list of all your repos."""
72
+ from app.services.github import get_user_repos
73
+
74
+ repos = get_user_repos()
75
+
76
+ for repo in repos:
77
+ typer.echo(f"{repo['full_name']}")
78
+ description = repo.get("description") or "—"
79
+ typer.echo(f"description: {format_description(description)}")
80
+ typer.echo(
81
+ f"visibility: {repo.get('visibility') or ('private' if repo.get('private') else 'public')}"
82
+ )
83
+ typer.echo(f"language: {repo.get('language') or '—'}")
84
+ typer.echo(f"default branch: {repo.get('default_branch') or '—'}")
85
+ typer.echo(
86
+ f"stars: {repo.get('stargazers_count', 0)} forks: {repo.get('forks_count', 0)} open issues: {repo.get('open_issues_count', 0)}"
87
+ )
88
+ typer.echo(f" url: {repo['html_url']}")
89
+
90
+ topics = repo.get("topics") or []
91
+ if topics:
92
+ typer.echo(f" topics: {format_topics(topics)}")
93
+
94
+ typer.echo("")
95
+
96
+
97
+ @app.command()
98
+ def details(
99
+ owner: str = typer.Argument(..., help="Repository owner, e.g. owenpalfreymandev"),
100
+ repo: str = typer.Argument(..., help="Repository name, e.g. reconcli"),
101
+ contributors: bool = typer.Option(False, help="View contributors in more detail."),
102
+ languages: bool = typer.Option(False, help="View language usage in more detail."),
103
+ ):
104
+ """Gain insights into your repo"""
105
+ if contributors and languages:
106
+ raise typer.BadParameter("Choose either --contributors or --languages.")
107
+
108
+ from app.services.github import (
109
+ ContributorResults,
110
+ get_authenticated_user,
111
+ get_languages,
112
+ get_repo_details,
113
+ get_top_contributors,
114
+ )
115
+ from app.ui.repo import display_view_header
116
+
117
+ details = get_repo_details(owner, repo)
118
+
119
+ if languages:
120
+ from app.ui.repo import display_languages
121
+
122
+ display_languages(
123
+ details.get("full_name", f"{owner}/{repo}"),
124
+ get_languages(owner, repo),
125
+ )
126
+ return
127
+
128
+ if contributors:
129
+ from app.ui.repo import display_contributors
130
+
131
+ try:
132
+ authenticated_user = get_authenticated_user()
133
+ except RuntimeError:
134
+ # Contributor statistics remain useful when GitHub cannot identify
135
+ # the token owner for this request.
136
+ authenticated_user = {}
137
+ results = cast(
138
+ ContributorResults,
139
+ get_top_contributors(
140
+ owner,
141
+ repo,
142
+ limit=5,
143
+ current_login=authenticated_user.get("login"),
144
+ include_metadata=True,
145
+ ),
146
+ )
147
+ display_contributors(
148
+ details.get("full_name", f"{owner}/{repo}"),
149
+ results.contributors,
150
+ results.total_contributors,
151
+ results.total_commits,
152
+ authenticated_user.get("login"),
153
+ results.current_contributor,
154
+ )
155
+ return
156
+
157
+ language_data = get_languages(owner, repo)
158
+ contributions = cast(
159
+ builtins_list[dict[str, str | int | None]],
160
+ get_top_contributors(owner, repo, limit=5),
161
+ )
162
+
163
+ display_view_header("Details", details.get("full_name", f"{owner}/{repo}"))
164
+
165
+ # Repo Details
166
+ typer.echo("Repository")
167
+ typer.echo("-----------")
168
+ typer.echo(f"{details.get('full_name', f'{owner}/{repo}')}") # Name
169
+ description = details.get("description") or "—"
170
+ typer.echo(f"description: {format_description(description)}") # Description
171
+ typer.echo(
172
+ f"visibility: {details.get('visibility') or ('private' if details.get('private') else 'public')}" # Visibility
173
+ )
174
+ typer.echo(
175
+ f"url: {details.get('html_url') or f'https://github.com/{owner}/{repo}'}"
176
+ ) # URL
177
+
178
+ # Stats
179
+ typer.echo("")
180
+ typer.echo("Stats")
181
+ typer.echo("-----------")
182
+ typer.echo(f"stars: {details.get('stargazers_count') or 0}") # Stars
183
+ typer.echo(f"forks: {details.get('forks_count')}") # Forks
184
+ typer.echo(f"issues: {details.get('open_issues_count') or 0}") # Issues
185
+ typer.echo(f"size: {format_size(details.get('size'))}") # Size
186
+
187
+ typer.echo("")
188
+ typer.echo("Contributions")
189
+ typer.echo("-----------")
190
+ if not contributions:
191
+ typer.echo("No contributor data returned.")
192
+ else:
193
+ for contributor in contributions:
194
+ typer.echo(f"{contributor['login']}: {contributor['commits']} commits")
195
+
196
+ # Tech
197
+ typer.echo("")
198
+ typer.echo("Languages")
199
+ typer.echo("---------")
200
+
201
+ for language in format_languages(language_data):
202
+ typer.echo(language)
app/services/auth.py ADDED
@@ -0,0 +1,79 @@
1
+ import os
2
+ import time
3
+ import webbrowser
4
+
5
+ import requests
6
+ from dotenv import load_dotenv
7
+
8
+ from app.services.storage import clear_token, save_token
9
+
10
+ load_dotenv()
11
+
12
+ CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
13
+
14
+ DEVICE_CODE_URL = "https://github.com/login/device/code"
15
+ TOKEN_URL = "https://github.com/login/oauth/access_token"
16
+
17
+
18
+ def login():
19
+ if not CLIENT_ID:
20
+ raise RuntimeError("Missing GITHUB_CLIENT_ID in .env")
21
+
22
+ device = request_device_code()
23
+
24
+ print("Opening GitHub authentication...")
25
+
26
+ webbrowser.open(device.get("verification_uri_complete", device["verification_uri"]))
27
+
28
+ print(f"If required, enter code: {device['user_code']}")
29
+
30
+ token = poll_for_token(device)
31
+
32
+ save_token(token)
33
+
34
+ print("Successfully logged into GitHub!")
35
+
36
+
37
+ def request_device_code():
38
+ response = requests.post(
39
+ DEVICE_CODE_URL,
40
+ headers={"Accept": "application/json"},
41
+ data={"client_id": CLIENT_ID, "scope": "read:user repo"},
42
+ timeout=10,
43
+ )
44
+
45
+ response.raise_for_status()
46
+
47
+ return response.json()
48
+
49
+
50
+ def poll_for_token(device):
51
+ interval = device.get("interval", 5)
52
+
53
+ while True:
54
+ response = requests.post(
55
+ TOKEN_URL,
56
+ headers={"Accept": "application/json"},
57
+ data={
58
+ "client_id": CLIENT_ID,
59
+ "device_code": device["device_code"],
60
+ "grant_type": ("urn:ietf:params:oauth:grant-type:device_code"),
61
+ },
62
+ timeout=10,
63
+ )
64
+
65
+ data = response.json()
66
+
67
+ if "access_token" in data:
68
+ return data["access_token"]
69
+
70
+ if data.get("error") != "authorization_pending":
71
+ raise RuntimeError(data)
72
+
73
+ time.sleep(interval)
74
+
75
+
76
+ def logout():
77
+ clear_token()
78
+
79
+ print("Successfully logged out of GitHub!")
app/services/github.py ADDED
@@ -0,0 +1,184 @@
1
+ import time
2
+ from dataclasses import dataclass
3
+ from urllib.parse import quote
4
+
5
+ import requests
6
+
7
+ from app.services.github_errors import (
8
+ github_headers,
9
+ raise_for_github_error,
10
+ )
11
+ from app.services.storage import get_token
12
+
13
+ GITHUB_API = "https://api.github.com"
14
+
15
+ TOP_CONTRIBUTORS = 10
16
+ STATS_POLL_ATTEMPTS = 5
17
+ STATS_POLL_INTERVAL_SECONDS = 1
18
+
19
+
20
+ @dataclass
21
+ class ContributorResults:
22
+ """Top contributor rows plus metadata from GitHub's statistics response."""
23
+
24
+ contributors: list[dict[str, str | int | None]]
25
+ total_contributors: int
26
+ total_commits: int
27
+ current_contributor: dict[str, str | int | None] | None = None
28
+
29
+
30
+ def _get_auth_headers():
31
+ token = get_token()
32
+
33
+ if not token:
34
+ raise RuntimeError("Not authenticated with GitHub. Run `auth login`.")
35
+
36
+ return github_headers(token)
37
+
38
+
39
+ def get_authenticated_user():
40
+ response = requests.get(
41
+ f"{GITHUB_API}/user",
42
+ headers=_get_auth_headers(),
43
+ timeout=10,
44
+ )
45
+
46
+ raise_for_github_error(response)
47
+
48
+ return response.json()
49
+
50
+
51
+ def get_user(username: str):
52
+ """Return a GitHub user's public profile."""
53
+ if not username:
54
+ raise ValueError("username must not be empty")
55
+
56
+ response = requests.get(
57
+ f"{GITHUB_API}/users/{quote(username, safe='')}",
58
+ headers=_get_auth_headers(),
59
+ timeout=10,
60
+ )
61
+
62
+ raise_for_github_error(response)
63
+
64
+ return response.json()
65
+
66
+
67
+ def get_user_repos():
68
+ response = requests.get(
69
+ f"{GITHUB_API}/user/repos",
70
+ headers=_get_auth_headers(),
71
+ timeout=10,
72
+ )
73
+
74
+ raise_for_github_error(response)
75
+
76
+ return response.json()
77
+
78
+
79
+ def get_repo_details(owner: str, repo: str):
80
+ response = requests.get(
81
+ f"{GITHUB_API}/repos/{owner}/{repo}",
82
+ headers=_get_auth_headers(),
83
+ timeout=10,
84
+ )
85
+
86
+ raise_for_github_error(response)
87
+
88
+ return response.json()
89
+
90
+
91
+ def get_languages(owner: str, repo: str):
92
+ response = requests.get(
93
+ f"{GITHUB_API}/repos/{owner}/{repo}/languages",
94
+ headers=_get_auth_headers(),
95
+ timeout=10,
96
+ )
97
+
98
+ raise_for_github_error(response)
99
+
100
+ return response.json()
101
+
102
+
103
+ def get_top_contributors(
104
+ owner: str,
105
+ repo: str,
106
+ limit: int = TOP_CONTRIBUTORS,
107
+ current_login: str | None = None,
108
+ include_metadata: bool = False,
109
+ ) -> list[dict[str, str | int | None]] | ContributorResults:
110
+ """
111
+ Return contributors ranked by commit count.
112
+
113
+ GitHub may return 202 while it calculates statistics,
114
+ so we retry until the data is available.
115
+ """
116
+
117
+ if limit < 1:
118
+ raise ValueError("limit must be at least 1")
119
+
120
+ url = f"{GITHUB_API}/repos/{owner}/{repo}/stats/contributors"
121
+
122
+ for attempt in range(STATS_POLL_ATTEMPTS):
123
+ response = requests.get(
124
+ url,
125
+ headers=_get_auth_headers(),
126
+ timeout=10,
127
+ )
128
+
129
+ if response.status_code == 202:
130
+ if attempt < STATS_POLL_ATTEMPTS - 1:
131
+ time.sleep(STATS_POLL_INTERVAL_SECONDS)
132
+ continue
133
+
134
+ raise RuntimeError(
135
+ "GitHub is still computing contributor statistics. Try again shortly."
136
+ )
137
+
138
+ raise_for_github_error(response)
139
+
140
+ statistics = [
141
+ contributor
142
+ for contributor in response.json()
143
+ if contributor.get("author") is not None
144
+ ]
145
+ contributors = sorted(
146
+ statistics,
147
+ key=lambda contributor: contributor["total"],
148
+ reverse=True,
149
+ )
150
+
151
+ def format_contributor(contributor: dict) -> dict[str, str | int | None]:
152
+ author = contributor["author"]
153
+ return {
154
+ "login": author["login"],
155
+ "commits": contributor["total"],
156
+ "profile_url": author["html_url"],
157
+ "avatar_url": author["avatar_url"],
158
+ }
159
+
160
+ current_contributor = next(
161
+ (
162
+ format_contributor(contributor)
163
+ for contributor in contributors
164
+ if current_login
165
+ and contributor["author"]["login"].casefold()
166
+ == current_login.casefold()
167
+ ),
168
+ None,
169
+ )
170
+
171
+ top_contributors = [
172
+ format_contributor(contributor) for contributor in contributors[:limit]
173
+ ]
174
+ if not include_metadata:
175
+ return top_contributors
176
+
177
+ return ContributorResults(
178
+ contributors=top_contributors,
179
+ total_contributors=len(contributors),
180
+ total_commits=sum(contributor["total"] for contributor in contributors),
181
+ current_contributor=current_contributor,
182
+ )
183
+
184
+ return ContributorResults([], 0, 0) if include_metadata else []
@@ -0,0 +1,39 @@
1
+ from requests import Response
2
+
3
+
4
+ def github_headers(token: str) -> dict[str, str]:
5
+ """Build headers for GitHub REST API requests."""
6
+ return {
7
+ "Authorization": f"Bearer {token}",
8
+ "Accept": "application/vnd.github+json",
9
+ "X-GitHub-Api-Version": "2026-03-10",
10
+ }
11
+
12
+
13
+ def raise_for_github_error(response: Response) -> None:
14
+ """Raise a useful error message for failed GitHub API requests."""
15
+ if response.ok:
16
+ return
17
+
18
+ try:
19
+ message = response.json().get("message", response.reason)
20
+ except ValueError:
21
+ message = response.text or response.reason
22
+
23
+ details = [f"GitHub API returned {response.status_code}: {message}"]
24
+
25
+ request_id = response.headers.get("X-GitHub-Request-Id")
26
+ if request_id:
27
+ details.append(f"request ID: {request_id}")
28
+
29
+ if response.status_code in {403, 429}:
30
+ remaining = response.headers.get("X-RateLimit-Remaining")
31
+
32
+ if remaining == "0":
33
+ details.append("primary API rate limit exhausted")
34
+
35
+ retry_after = response.headers.get("Retry-After")
36
+ if retry_after:
37
+ details.append(f"retry after {retry_after} seconds")
38
+
39
+ raise RuntimeError("; ".join(details))
@@ -0,0 +1,27 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ ATLAS_DIR = Path.home() / ".atlas"
5
+ CONFIG_FILE = ATLAS_DIR / "config.json"
6
+
7
+
8
+ def save_token(token: str):
9
+ ATLAS_DIR.mkdir(exist_ok=True)
10
+
11
+ data = {"github_token": token}
12
+
13
+ CONFIG_FILE.write_text(json.dumps(data, indent=4), encoding="utf-8")
14
+
15
+
16
+ def get_token():
17
+ if not CONFIG_FILE.exists():
18
+ return None
19
+
20
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
21
+
22
+ return data.get("github_token")
23
+
24
+
25
+ def clear_token():
26
+ if CONFIG_FILE.exists():
27
+ CONFIG_FILE.unlink()
app/ui/avatar.py ADDED
@@ -0,0 +1,24 @@
1
+ from io import BytesIO
2
+
3
+ import requests
4
+ from PIL import Image
5
+ from rich_pixels import Pixels
6
+
7
+
8
+ def get_profile_picture(user: dict):
9
+ """Return user profile picture as a small Rich renderable."""
10
+
11
+ avatar_url = user.get("avatar_url")
12
+
13
+ if not avatar_url:
14
+ return ""
15
+
16
+ response = requests.get(avatar_url, timeout=10)
17
+ response.raise_for_status()
18
+
19
+ image = Image.open(BytesIO(response.content))
20
+
21
+ # Resize avatar for terminal display
22
+ image.thumbnail((28, 28))
23
+
24
+ return Pixels.from_image(image)
app/ui/display.py ADDED
@@ -0,0 +1,53 @@
1
+ from rich.columns import Columns
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+
6
+ from app.ui.avatar import get_profile_picture
7
+
8
+ console = Console()
9
+
10
+
11
+ def display_user(user: dict):
12
+ """Display a GitHub profile with avatar."""
13
+
14
+ title = user.get("name") or user["login"]
15
+ subtitle = f"@{user['login']}"
16
+
17
+ profile = Table(show_header=False, box=None, pad_edge=False)
18
+ profile.add_column("Field", style="cyan")
19
+ profile.add_column("Value")
20
+
21
+ profile.add_row("Repositories", str(user["public_repos"]))
22
+ profile.add_row("Followers", str(user["followers"]))
23
+ profile.add_row("Following", str(user["following"]))
24
+ profile.add_row("Location", user.get("location") or "—")
25
+ profile.add_row("Company", user.get("company") or "—")
26
+ profile.add_row("Joined", user["created_at"][:10])
27
+
28
+ profile_panel = Panel(
29
+ profile,
30
+ title=f"[bold]{title}[/bold]",
31
+ subtitle=subtitle,
32
+ expand=False,
33
+ )
34
+
35
+ avatar_panel = Panel(
36
+ get_profile_picture(user),
37
+ # Pixels does not report its natural width to Rich, so constrain this
38
+ # panel to the 28-column thumbnail plus its border and horizontal padding.
39
+ width=32,
40
+ padding=(0, 1),
41
+ expand=False,
42
+ )
43
+
44
+ console.print(
45
+ Columns(
46
+ [
47
+ profile_panel,
48
+ avatar_panel,
49
+ ],
50
+ expand=False,
51
+ equal=False,
52
+ )
53
+ )
app/ui/repo.py ADDED
@@ -0,0 +1,365 @@
1
+ from rich.columns import Columns
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+ from rich.text import Text
6
+
7
+ console = Console()
8
+
9
+
10
+ def build_view_header(title: str, subtitle: str) -> Panel:
11
+ """Create the shared header used by focused repository views."""
12
+ content = Text(justify="center")
13
+ content.append(title.upper(), style="bold cyan")
14
+ content.append("\n")
15
+ content.append(subtitle, style="bold")
16
+ return Panel(content, width=60, border_style="cyan")
17
+
18
+
19
+ def display_view_header(title: str, subtitle: str) -> None:
20
+ """Render the shared header used by focused repository views."""
21
+ console.print(build_view_header(title, subtitle))
22
+
23
+
24
+ def format_size(size: int | None) -> str:
25
+ # Format GitHub's repository size value for display.
26
+ if size is None:
27
+ return "Not available"
28
+
29
+ units = ["KB", "MB", "GB", "TB", "PB"]
30
+ value = size
31
+ current_unit = 0
32
+
33
+ while value >= 1000 and current_unit < len(units) - 1:
34
+ value /= 1000
35
+ current_unit += 1
36
+
37
+ return f"{value:.1f} {units[current_unit]}"
38
+
39
+
40
+ def format_bytes(size: int | None) -> str:
41
+ """Format byte size values for display."""
42
+ if size is None:
43
+ return "Not available"
44
+
45
+ units = ["B", "KB", "MB", "GB", "TB"]
46
+ value = float(size)
47
+ current_unit = 0
48
+
49
+ while value >= 1000 and current_unit < len(units) - 1:
50
+ value /= 1000
51
+ current_unit += 1
52
+
53
+ if current_unit == 0:
54
+ return f"{int(value)} B"
55
+ return f"{value:.1f} {units[current_unit]}"
56
+
57
+
58
+ def display_repo_details(
59
+ details: dict,
60
+ languages: list[str],
61
+ contributions: list[dict],
62
+ ):
63
+ """Display a repository overview."""
64
+
65
+ full_name = details.get("full_name", "Unknown repository")
66
+ description = details.get("description") or "No description provided."
67
+ visibility = details.get("visibility") or (
68
+ "private" if details.get("private") else "public"
69
+ )
70
+ primary_language = details.get("language") or "—"
71
+ url = details.get("html_url") or "—"
72
+
73
+ header_panel = build_view_header("Details", full_name)
74
+
75
+ # Statistics
76
+ stats = Table(
77
+ show_header=False,
78
+ box=None,
79
+ pad_edge=False,
80
+ )
81
+
82
+ stats.add_column("Metric", style="dim")
83
+ stats.add_column("Value", justify="right")
84
+
85
+ stats.add_row(
86
+ "★ Stars",
87
+ str(details.get("stargazers_count") or 0),
88
+ )
89
+ stats.add_row(
90
+ "⑂ Forks",
91
+ str(details.get("forks_count") or 0),
92
+ )
93
+ stats.add_row(
94
+ "! Issues",
95
+ str(details.get("open_issues_count") or 0),
96
+ )
97
+ stats.add_row(
98
+ "Size",
99
+ format_size(details.get("size")),
100
+ )
101
+
102
+ stats_panel = Panel(
103
+ stats,
104
+ title="Statistics",
105
+ expand=False,
106
+ )
107
+
108
+ # Contributions
109
+ contribution_table = Table(
110
+ show_header=False,
111
+ box=None,
112
+ pad_edge=False,
113
+ )
114
+
115
+ contribution_table.add_column("Contributor")
116
+ contribution_table.add_column("Commits", justify="right")
117
+
118
+ if contributions:
119
+ for contributor in contributions:
120
+ contribution_table.add_row(
121
+ contributor["login"],
122
+ str(contributor["commits"]),
123
+ )
124
+ else:
125
+ contribution_table.add_row(
126
+ "No contributor data",
127
+ "—",
128
+ )
129
+
130
+ contributions_panel = Panel(
131
+ contribution_table,
132
+ title="Contributions",
133
+ expand=False,
134
+ )
135
+
136
+ # Languages
137
+ language_table = Table(
138
+ show_header=False,
139
+ box=None,
140
+ pad_edge=False,
141
+ )
142
+
143
+ language_table.add_column("Language")
144
+ language_table.add_column("Usage")
145
+
146
+ for language in languages:
147
+ language_name, percentage = language.split(": ")
148
+ language_table.add_row(
149
+ language_name,
150
+ percentage,
151
+ )
152
+
153
+ languages_panel = Panel(
154
+ language_table,
155
+ title="Languages",
156
+ expand=False,
157
+ )
158
+
159
+ console.print(header_panel)
160
+ console.print(Text(description, style="dim"))
161
+ console.print(
162
+ Text.assemble(
163
+ (visibility.capitalize(), "cyan"),
164
+ (" "),
165
+ (primary_language, "green"),
166
+ )
167
+ )
168
+ console.print()
169
+
170
+ console.print(
171
+ Columns(
172
+ [stats_panel, contributions_panel],
173
+ expand=False,
174
+ equal=True,
175
+ )
176
+ )
177
+
178
+ console.print(languages_panel)
179
+
180
+ console.print(
181
+ Text(
182
+ f"↗ {url}",
183
+ style="dim",
184
+ )
185
+ )
186
+
187
+
188
+ def display_contributors(
189
+ full_name: str,
190
+ contributors: list[dict],
191
+ total_contributors: int,
192
+ total_commits: int,
193
+ current_login: str | None = None,
194
+ current_contributor: dict | None = None,
195
+ ):
196
+ """Display a focused view of GitHub contributor statistics."""
197
+ display_view_header("Contributors", full_name)
198
+
199
+ shown_count = len(contributors)
200
+ if not total_contributors:
201
+ console.print(Text("No contributor data returned.", style="dim"))
202
+ return
203
+
204
+ console.print(
205
+ Text(
206
+ f"{total_contributors} contributors returned by GitHub · showing top {shown_count}",
207
+ style="dim",
208
+ )
209
+ )
210
+
211
+ current_is_ranked = any(
212
+ current_login and contributor["login"].casefold() == current_login.casefold()
213
+ for contributor in contributors
214
+ )
215
+ largest_count = max(contributor["commits"] for contributor in contributors)
216
+
217
+ if current_contributor and not current_is_ranked:
218
+ console.print()
219
+ console.print(Text("YOUR CONTRIBUTION", style="bold cyan"))
220
+ console.print(Text("─" * 58, style="dim"))
221
+ console.print(
222
+ _contributor_row(
223
+ current_contributor,
224
+ total_commits,
225
+ largest_count,
226
+ marker="● ",
227
+ highlight=True,
228
+ )
229
+ )
230
+
231
+ console.print()
232
+ console.print(Text("TOP CONTRIBUTORS", style="bold"))
233
+ console.print(Text("─" * 58, style="dim"))
234
+ for rank, contributor in enumerate(contributors, start=1):
235
+ is_current_user = bool(
236
+ current_login
237
+ and contributor["login"].casefold() == current_login.casefold()
238
+ )
239
+ console.print(
240
+ _contributor_row(
241
+ contributor,
242
+ total_commits,
243
+ largest_count,
244
+ rank=rank,
245
+ marker="● " if is_current_user else " ",
246
+ highlight=is_current_user,
247
+ )
248
+ )
249
+ if rank < shown_count:
250
+ console.print()
251
+
252
+ console.print(Text("─" * 58, style="dim"))
253
+ console.print(
254
+ Text(
255
+ f"Showing {shown_count} of {total_contributors} contributors returned by GitHub",
256
+ style="dim",
257
+ )
258
+ )
259
+
260
+
261
+ def display_languages(full_name: str, languages: dict[str, int]) -> None:
262
+ """Display all languages reported by GitHub with proportional usage bars."""
263
+ display_view_header("Languages", full_name)
264
+
265
+ if not languages:
266
+ console.print(Text("No language data returned.", style="dim"))
267
+ return
268
+
269
+ total_bytes = sum(languages.values())
270
+ ranked_languages = sorted(
271
+ languages.items(),
272
+ key=lambda language: language[1],
273
+ reverse=True,
274
+ )
275
+ largest_count = ranked_languages[0][1]
276
+ language_count = len(ranked_languages)
277
+
278
+ console.print(
279
+ Text(
280
+ f"{language_count} languages returned by GitHub · 100% of reported code",
281
+ style="dim",
282
+ )
283
+ )
284
+ console.print()
285
+ console.print(Text("LANGUAGE BREAKDOWN", style="bold"))
286
+ console.print(Text("─" * 58, style="dim"))
287
+
288
+ for index, (language, byte_count) in enumerate(ranked_languages, start=1):
289
+ console.print(
290
+ _language_row(
291
+ language,
292
+ byte_count,
293
+ total_bytes,
294
+ largest_count,
295
+ rank=index,
296
+ )
297
+ )
298
+ if index < language_count:
299
+ console.print()
300
+
301
+ console.print(Text("─" * 58, style="dim"))
302
+ console.print(Text(f"Showing all {language_count} languages", style="dim"))
303
+
304
+
305
+ def _contributor_row(
306
+ contributor: dict,
307
+ total_commits: int,
308
+ largest_count: int,
309
+ rank: int | None = None,
310
+ marker: str = "",
311
+ highlight: bool = False,
312
+ ) -> Text:
313
+ """Build one compact contributor row with a proportional contribution bar."""
314
+ commits = int(contributor.get("commits") or 0)
315
+ percentage = commits / total_commits * 100 if total_commits else 0
316
+ bar_width = 32
317
+ filled = (
318
+ max(1, round(commits / largest_count * bar_width))
319
+ if commits and largest_count
320
+ else 0
321
+ )
322
+ bar = "█" * filled
323
+ username_style = "bold cyan" if highlight else ""
324
+ bar_style = "cyan" if highlight else "green"
325
+ commit_label = "commit" if commits == 1 else "commits"
326
+
327
+ row = Text()
328
+ if rank is not None:
329
+ row.append(f"{rank:<2} ", style="dim")
330
+ row.append(marker, style="cyan" if highlight else "dim")
331
+ row.append(f"{contributor.get('login', 'Unknown'):<30}", style=username_style)
332
+ row.append(f"{commits:>6} {commit_label}\n")
333
+ row.append(" " * (4 if rank is not None else 3))
334
+ row.append(bar.ljust(bar_width), style=bar_style)
335
+ row.append(f" {percentage:.1f}%", style="dim")
336
+ return row
337
+
338
+
339
+ def _language_row(
340
+ language: str,
341
+ byte_count: int,
342
+ total_bytes: int,
343
+ largest_count: int,
344
+ rank: int | None = None,
345
+ ) -> Text:
346
+ """Build one compact language row with a proportional usage bar."""
347
+ percentage = byte_count / total_bytes * 100 if total_bytes else 0
348
+ bar_width = 32
349
+ filled = (
350
+ max(1, round(byte_count / largest_count * bar_width))
351
+ if byte_count and largest_count
352
+ else 0
353
+ )
354
+ bar = "█" * filled
355
+
356
+ row = Text()
357
+ if rank is not None:
358
+ row.append(f"{rank:<2} ", style="dim")
359
+ row.append(" ", style="dim")
360
+ row.append(f"{language:<30}")
361
+ row.append(f"{format_bytes(byte_count):>12}\n")
362
+ row.append(" " * (4 if rank is not None else 3))
363
+ row.append(bar.ljust(bar_width), style="green")
364
+ row.append(f" {percentage:.1f}%", style="dim")
365
+ return row
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.4
2
+ Name: recon-github
3
+ Version: 0.1.0
4
+ Summary: Recon is a powerful CLI companion for developers and tools for agents, turning GitHub activity and repository data into useful insights from your terminal
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: dotenv>=0.9.9
9
+ Requires-Dist: requests>=2.34.2
10
+ Requires-Dist: rich>=15.0.0
11
+ Requires-Dist: rich-pixels>=3.0.1
12
+ Requires-Dist: typer>=0.27.1
13
+ Dynamic: license-file
14
+
15
+
16
+ # Recon CLI
17
+
18
+ Recon is a powerful CLI companion for developers and tools for agents, turning GitHub activity and repository data into useful insights from your terminal.
19
+
20
+
21
+
22
+ [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](https://choosealicense.com/licenses/mit/)
23
+ [![CI](https://github.com/owenpalfreymandev/reconcli/actions/workflows/ci.yml/badge.svg)](https://github.com/owenpalfreymandev/reconcli/actions/workflows/ci.yml)
24
+ [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/)
25
+ [![PyPI](https://img.shields.io/pypi/v/recon-cli)](https://pypi.org/)
26
+ [![License](https://img.shields.io/github/license/owenpalfreymandev/reconcli)](https://github.com/owenpalfreymandev/reconcli/blob/main/LICENSE)
27
+ ## Overview
28
+
29
+ Recon allows for developers to view basic GitHub analytics in the terminal quickly, rather than opening up their browser. This means it allows agents to interect with the service without having browser capabilities.
30
+
31
+ It is powered by [Typer](github.com/fastapi/typer), a library for building fast CLI tools, and made pretty by [Rich](https://github.com/textualize/rich) and it's prebuilt components.
32
+ ## Features
33
+
34
+ * **GitHub Authentication** — Securely authenticate with GitHub using OAuth device flow.
35
+ * **User Information** — View information about your authenticated GitHub account.
36
+ * **Repository Explorer** — List and inspect GitHub repositories directly from the terminal.
37
+ * **Repository Statistics** — Explore repository languages, contributors, and other useful statistics.
38
+ * **Rich Terminal UI** — Clean, structured terminal output powered by Rich.
39
+ * **Fast CLI Workflow** — Quickly access GitHub information without leaving the command line.
40
+ * **Modular Architecture** — Built with reusable services and components to make Recon easy to maintain and extend.
41
+ * **Automated Testing** — Tested with an automated CI pipeline to help maintain reliability and code quality.
42
+
43
+ ## Installation
44
+
45
+ Since we want you to be able to use Recon from anywhere - not just one directory - we recomend you install it with [uv package manager](https://docs.astral.sh/uv/getting-started/installation/).
46
+
47
+ ```bash
48
+ uv tool install recon-gh
49
+ ```
50
+ For the best experience, then login to your GitHub account (this step is not strictly required).
51
+ ```bash
52
+ recon login
53
+ ```
54
+ ## Usage
55
+
56
+ Here you can learn how to get started with terminal commands, and learn how to find more.
57
+
58
+ ### View Your GitHub Profile
59
+
60
+ The `me` command displays information about your authenticated GitHub account.
61
+
62
+ ```bash
63
+ recon me
64
+ ```
65
+
66
+ Example output:
67
+
68
+ ```text
69
+ ╭────── Owen Palfreyman ───────╮ ╭──────────────────────────────╮
70
+ │ Repositories 2 │ │ │
71
+ │ Followers 7 │ │ │
72
+ │ Following 2 │ │ │
73
+ │ Location United Kingdom │ │ │
74
+ │ Company — │ │ │
75
+ │ Joined 2023-11-02 │ │ │
76
+ ╰───── @owenpalfreymandev ─────╯ │ │
77
+ ╰──────────────────────────────╯
78
+ ```
79
+
80
+ ### Scout Another GitHub User
81
+
82
+ Use `scout` to explore another GitHub user's profile.
83
+
84
+ ```bash
85
+ recon scout torvalds
86
+ ```
87
+
88
+ Example output:
89
+
90
+ ```text
91
+ ╭──────── Linus Torvalds ────────╮ ╭──────────────────────────────╮
92
+ │ Repositories 12 │ │ │
93
+ │ Followers 318572 │ │ │
94
+ │ Following 0 │ │ │
95
+ │ Location Portland, OR │ │ │
96
+ │ Company Linux Foundation │ │ │
97
+ │ Joined 2011-09-03 │ │ │
98
+ ╰────────── @torvalds ───────────╯ │ │
99
+ ╰──────────────────────────────╯
100
+ ```
101
+
102
+ ### List Your Repositories
103
+
104
+ The `list` command displays your GitHub repositories.
105
+
106
+ ```bash
107
+ recon list
108
+ ```
109
+
110
+ This provides a quick way to see your repositories before using `details` to explore a specific project.
111
+
112
+ ### Authentication
113
+
114
+ Recon uses GitHub's device flow for authentication.
115
+
116
+ To log in:
117
+
118
+ ```bash
119
+ recon login
120
+ ```
121
+
122
+ To log out:
123
+
124
+ ```bash
125
+ recon logout
126
+ ```
127
+
128
+ ### Explore a Repository
129
+
130
+ The `details` command gives you an overview of a GitHub repository.
131
+
132
+ ```bash
133
+ recon details owenpalfreymandev fpark
134
+ ```
135
+
136
+ The command takes two arguments:
137
+
138
+ * `owner` — The GitHub username or organisation that owns the repository.
139
+ * `repo` — The name of the repository.
140
+
141
+ ### Need Some Help?
142
+
143
+ Not sure what a command can do? Recon has built-in help for every command.
144
+
145
+ ```bash
146
+ recon details --help
147
+ ```
148
+
149
+ This shows you the available arguments and options for the command, including additional ways to get information from a repository.
150
+
151
+ ```text
152
+ Usage: recon details [OPTIONS] {owner} {repo}
153
+
154
+ Gain insights into your repo
155
+
156
+ Arguments:
157
+ owner Repository owner, e.g. owenpalfreymandev
158
+ repo Repository name, e.g. reconcli
159
+
160
+ Options:
161
+ --contributors View contributors in more detail.
162
+ --languages View language usage in more detail.
163
+ --help Show this message and exit.
164
+ ```
165
+
166
+ For example, the `--languages` option can be used to get a more detailed breakdown of the languages used in a repository:
167
+
168
+ ```bash
169
+ recon details owenpalfreymandev fpark --languages
170
+ ```
171
+
172
+ ```text
173
+ ╭──────────────────────────────────────────────────────────╮
174
+ │ LANGUAGES │
175
+ │ owenpalfreymandev/fpark │
176
+ ╰──────────────────────────────────────────────────────────╯
177
+ 4 languages returned by GitHub · 100% of reported code
178
+
179
+ LANGUAGE BREAKDOWN
180
+ ──────────────────────────────────────────────────────────
181
+ 1 TypeScript 279.9 KB
182
+ ████████████████████████████████ 95.9%
183
+
184
+ 2 PLpgSQL 7.0 KB
185
+ █ 2.4%
186
+
187
+ 3 CSS 4.3 KB
188
+ █ 1.5%
189
+
190
+ 4 JavaScript 559 B
191
+ █ 0.2%
192
+ ──────────────────────────────────────────────────────────
193
+ Showing all 4 languages
194
+ ```
195
+
196
+
197
+ ### Explore Further
198
+
199
+ These are just some of the commands available in Recon. There are plenty more options to play with, with more commands and features coming soon. Check the [roadmap](https://github.com/owenpalfreymandev/reconcli/blob/main/README.md) for more details.
200
+
201
+ ## Contributing
202
+
203
+ Contributions are always welcome!
204
+
205
+ To clone the repo and download all dependencies, run:
206
+ ```bash
207
+ git clone https://github.com/owenpalfreymandev/reconcli.git
208
+ uv sync
209
+ ```
210
+
211
+ You can run unit tests and type tests with:
212
+ ```bash
213
+ uv run pytest
214
+ uv run pyright
215
+ ```
216
+
217
+ Be sure to lint before commiting, or it will fail CI/CD:
218
+ ```bash
219
+ uv run ruff check --fix .
220
+ ```
221
+
222
+ ### Workflow
223
+
224
+ A basic workflow to follow is:
225
+
226
+ 1. Fork the repository
227
+ 2. Create a branch
228
+ 3. Make changes
229
+ 4. Run tests/lint/type checks
230
+ 5. Open a PR
231
+ ## License
232
+
233
+ Recon is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
234
+ ## Author
235
+
236
+ Recon is developed by Owen Palfreyman.
237
+
238
+ GitHub: @owenpalfreymandev
239
+ Repository: owenpalfreymandev/reconcli
240
+
241
+ *or could could just run `recon details owenpalfreymandev reconcli --contributors` wink wink*
@@ -0,0 +1,16 @@
1
+ app/cli.py,sha256=j_aGv8L7KRteU0Rt6sIaqdIXPjGz8CcczICuOoentlE,336
2
+ app/commands/auth.py,sha256=XP5IehUFW_NMXIzNHRjjFhQd0FeIQn3jjqs_4ZCw7IM,238
3
+ app/commands/me.py,sha256=FCJUYrTUw6rM2OkrGj421TzJJmC61cwEupy4AMUqDB8,434
4
+ app/commands/repo.py,sha256=TapGxTGWP7qJZB4nBju6lMbKFA9T-PVSW83XVbuKGjo,6357
5
+ app/services/auth.py,sha256=3QGHpdfikQblTWjn6j_ePCHet4AVGBtFR73JTZEIQHE,1801
6
+ app/services/github.py,sha256=N86ZFl9l41T_hHIXPvuBPi6fCRinfCVCVgU6dLy5JZY,4727
7
+ app/services/github_errors.py,sha256=14IpoR_mQMqky_tv2vjSrFAK-nO7_YhSbT6OhUgLs6U,1210
8
+ app/services/storage.py,sha256=bO_XKvfQFhS4nKDIkNuJXLFD12ThZi8ztPYOKyKIq24,540
9
+ app/ui/avatar.py,sha256=UAcqWQIk92HoI6TrG8nPvyrIFZhJg6dFNC8OJIu650o,526
10
+ app/ui/display.py,sha256=WUm6jybb3NWgGOE4Xicvj6OO80cwqiCuaKzI_gC-spc,1481
11
+ app/ui/repo.py,sha256=vxlqprTRne6TgCM6QBzVRDJvzklFxOE6xwvZXuoFvKs,10141
12
+ recon_github-0.1.0.dist-info/licenses/LICENSE,sha256=SeqZEVgNw2f7eXshxAwKfEH3Y6K-9QCY0QlrnJKGTBY,1074
13
+ recon_github-0.1.0.dist-info/METADATA,sha256=2pszzpJjIDLO_4Z3D0PqD31pSuWRJ6TI4rd6e1ZPjig,8893
14
+ recon_github-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ recon_github-0.1.0.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
16
+ recon_github-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 owenpalfreymandev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ app