feat: add cli. tui can be run with -t/--tui

This commit is contained in:
Ritchie Mwewa
2026-02-28 03:11:18 +02:00
parent 307a59cd9f
commit 31742efb5c
18 changed files with 157 additions and 165 deletions

View File

@@ -9,11 +9,10 @@ authors = [
]
requires-python = ">=3.13"
dependencies = [
"rich>=14.2.0",
"rich>=14.3.3",
"questionary>=2.1.1",
"pyfiglet>=1.0.4",
"update-checker>=0.18.0",
"black>=25.12.0",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -24,9 +23,9 @@ classifiers = [
]
[project.urls]
homepage = "https://bellingcat.com"
issues = "https://github.com/bellingcat/octosuite/issues"
repository = "https://github.com/bellingcat/octosuite"
Homepage = "https://bellingcat.com"
Issues = "https://github.com/bellingcat/octosuite/issues"
Repository = "https://github.com/bellingcat/octosuite"
[project.optional-dependencies]
dev = [
@@ -34,7 +33,7 @@ dev = [
]
[project.scripts]
octosuite = "octosuite._app:start"
octosuite = "octosuite.app.main:start_app"
[tool.uv]
package = true

View File

@@ -1,7 +1,4 @@
__pkg__ = "octosuite"
__version__ = "5.0.0"
from .api.cache import cache
from .api.models import User, Org, Repo, Search
from .core.cache import cache
from .core.models import User, Org, Repo, Search
__all__ = ["User", "Org", "Repo", "Search", "cache", "__pkg__", "__version__"]
__all__ = ["User", "Org", "Repo", "Search", "cache"]

View File

@@ -1,20 +0,0 @@
import sys
from ._lib import console, __pkg__, __version__
from .core.cache import cache
def start():
"""Entry point for octosuite."""
try:
console.set_window_title(title=f"{__pkg__.title()} CLI v{__version__}")
from ._cli import run
run()
except KeyboardInterrupt:
sys.exit()
finally:
cache.clear()

View File

@@ -6,7 +6,7 @@ import requests
from requests import Response
from .cache import cache
from .._lib import __version__
from ..meta import __version__
BASE_URL = "https://api.github.com"

View File

@@ -0,0 +1,4 @@
from . import cli, tui
from ..meta import __pkg__, __version__
__all__ = ["cli", "tui", "__pkg__", "__version__"]

View File

@@ -0,0 +1,3 @@
from .main import run_cli, arg_parser
__all__ = ["arg_parser", "run_cli"]

View File

@@ -1,22 +1,16 @@
"""
Command-line interface for octosuite.
Provides non-interactive access to GitHub data.
"""
import argparse
import json
import sys
import typing as t
from rich.status import Status
from ..lib import export_response, preview_response, console, check_updates
from ...api.models import User, Org, Repo, Search
from ...meta import __pkg__, __version__
from . import __pkg__, __version__
from ._lib import export_response, preview_response, console
from .core.models import User, Org, Repo, Search
__all__ = ["arg_parser", "run_cli"]
def create_parser() -> argparse.ArgumentParser:
def arg_parser() -> argparse.ArgumentParser:
"""
Create the argument parser.
@@ -35,7 +29,7 @@ def create_parser() -> argparse.ArgumentParser:
version=f"%(prog)s {__version__}, MIT Licence © Bellingcat",
)
parser.add_argument(
"-i", "--interactive", action="store_true", help="launch interactive TUI"
"-t", "--tui", action="store_true", help="launch interactive TUI"
)
parser.add_argument(
"-p", "--page", type=int, default=1, help="page number (default: %(default)s)"
@@ -361,136 +355,121 @@ def output(
preview_response(data=data, source=source, _type=data_type)
def run():
def run_cli(args: argparse.Namespace):
"""Run the CLI."""
parser = create_parser()
args = parser.parse_args()
if args.interactive:
from .tui import run as run_tui
run_tui()
return
if not args.command:
parser.print_help()
return
try:
if args.command == "user":
user = User(name=args.username)
with console.status("Initialising…") as status:
check_updates(is_cli=True, status=status)
if args.command == "user":
user = User(name=args.username)
with Status(
f"[dim]Validating user ({args.username})[/dim]…", console=console
):
status.update(f"[dim]Validating user ({args.username})…[/dim]")
exists, _ = user.exists()
if not exists:
console.print(f"[red]User '{args.username}' not found[/red]")
sys.exit(1)
if not exists:
console.print(f"[red]User '{args.username}' not found[/red]")
sys.exit(1)
method = getattr(user, args.data_type)
with Status(
f"[dim]Getting {args.data_type} from {args.username}[/dim]",
console=console,
):
method = getattr(user, args.data_type)
status.update(
f"[dim]Getting {args.data_type} from {args.username}[/dim]"
)
data = (
method()
if args.data_type == "profile"
else method(page=args.page, per_page=min(args.per_page, 100))
)
output(
data=data,
as_json=args.json,
source=args.username,
data_type=args.data_type,
export_dir=args.dir,
)
output(
data=data,
as_json=args.json,
source=args.username,
data_type=args.data_type,
export_dir=args.dir,
)
elif args.command == "repo":
if "/" not in args.repository:
console.print("[red]Repository must be in 'owner/name' format[/red]")
sys.exit(1)
elif args.command == "repo":
if "/" not in args.repository:
console.print(
"[red]Repository must be in 'owner/name' format[/red]"
)
sys.exit(1)
owner, name = args.repository.split("/", 1)
repo = Repo(name=name, owner=owner)
owner, name = args.repository.split("/", 1)
repo = Repo(name=name, owner=owner)
with Status(
f"[dim]Validating repo ({args.repository})[/dim]…", console=console
):
status.update(f"[dim]Validating repo ({args.repository})…[/dim]")
exists, _ = repo.exists()
if not exists:
console.print(f"[red]Repository '{args.repository}' not found[/red]")
sys.exit(1)
if not exists:
console.print(
f"[red]Repository '{args.repository}' not found[/red]"
)
sys.exit(1)
method = getattr(repo, args.data_type)
with Status(
f"[dim]Getting {args.data_type} from {args.repository}[/dim]",
console=console,
):
method = getattr(repo, args.data_type)
status.update(
f"[dim]Getting {args.data_type} from {args.repository}[/dim]"
)
data = (
method()
if args.data_type in ("profile", "languages")
else method(page=args.page, per_page=min(args.per_page, 100))
)
output(
data=data,
as_json=args.json,
source=args.repository,
data_type=args.data_type,
export_dir=args.dir,
)
output(
data=data,
as_json=args.json,
source=args.repository,
data_type=args.data_type,
export_dir=args.dir,
)
elif args.command == "org":
org = Org(name=args.name)
elif args.command == "org":
org = Org(name=args.name)
with Status(f"[dim]Validating org ({args.name})[/dim]", console=console):
status.update(f"[dim]Validating org ({args.name})[/dim]")
exists, _ = org.exists()
if not exists:
console.print(f"[red]Organisation '{args.name}' not found[/red]")
sys.exit(1)
if not exists:
console.print(f"[red]Organisation '{args.name}' not found[/red]")
sys.exit(1)
method = getattr(org, args.data_type)
with Status(
f"[dim]Getting {args.data_type} from {args.name}[/dim]…",
console=console,
):
method = getattr(org, args.data_type)
status.update(f"[dim]Getting {args.data_type} from {args.name}…[/dim]")
data = (
method()
if args.data_type == "profile"
else method(page=args.page, per_page=min(args.per_page, 100))
)
output(
data=data,
as_json=args.json,
source=args.name,
data_type=args.data_type,
export_dir=args.dir,
)
output(
data=data,
as_json=args.json,
source=args.name,
data_type=args.data_type,
export_dir=args.dir,
)
elif args.command == "search":
search = Search(
query=args.query,
page=args.page,
per_page=min(args.per_page, 100),
)
method = getattr(search, args.search_type)
with Status(
f"[dim]Searching {args.search_type} for '{args.query}'[/dim]",
console=console,
):
elif args.command == "search":
search = Search(
query=args.query,
page=args.page,
per_page=min(args.per_page, 100),
)
method = getattr(search, args.search_type)
status.update(
f"[dim]Searching {args.search_type} for '{args.query}'[/dim]"
)
result = method()
data = result.get("items", result) if isinstance(result, dict) else result
output(
data=data,
as_json=args.json,
source=args.query,
data_type=args.search_type,
export_dir=args.dir,
)
data = (
result.get("items", result) if isinstance(result, dict) else result
)
output(
data=data,
as_json=args.json,
source=args.query,
data_type=args.search_type,
export_dir=args.dir,
)
except KeyboardInterrupt:
console.print("\n[dim]Cancelled[/dim]")

View File

@@ -9,15 +9,14 @@ from pathlib import Path
import pyfiglet
from prompt_toolkit.shortcuts import message_dialog
from rich.console import Console
from rich.status import Status
from rich.text import Text
from rich.tree import Tree
from update_checker import UpdateChecker
from . import __pkg__, __version__
from ..meta import __pkg__, __version__
__all__ = [
"__pkg__",
"__version__",
"console",
"preview_response",
"export_response",
@@ -213,17 +212,28 @@ def fill_tree(tree: Tree, data: t.Union[dict, list]) -> Tree:
return tree
def check_updates():
"""Check for available package updates and display the result."""
def check_updates(is_cli: bool = False, status: t.Optional[Status] = None):
"""
Check for available package updates and display the result.
with console.status("[dim]Checking for updates[/dim]…") as status:
checker = UpdateChecker()
result = checker.check(__pkg__, __version__)
if result is not None:
status.stop()
:param status: The rich.status object for showing a live status.
:param is_cli: Whether we're running as a CLI.
"""
if isinstance(status, Status):
status.update("[dim]Checking for updates[/dim]…")
checker = UpdateChecker()
result = checker.check(__pkg__, __version__)
if result is not None:
if not is_cli:
if isinstance(status, Status):
status.stop()
message_dialog(title="Update Available", text=str(result)).run()
else:
status.stop()
console.print(result)
else:
if not is_cli:
message_dialog(
title="Up to Date",
text=f"You're running the current version, {__version__}",

15
src/octosuite/app/main.py Normal file
View File

@@ -0,0 +1,15 @@
from .cli import run_cli
from .cli.main import arg_parser
from .tui import run_tui
def start_app():
parser = arg_parser()
args = parser.parse_args()
if args.tui:
run_tui()
if args.command:
run_cli(args=args)
else:
parser.print_usage()

View File

@@ -1,11 +1,9 @@
"""Terminal user interface for octosuite."""
from .menus import Menus
__all__ = ["run", "Menus"]
__all__ = ["run_tui", "Menus"]
def run():
def run_tui():
"""Run the interactive TUI."""
menu = Menus()

View File

@@ -5,12 +5,17 @@ import questionary as q
from questionary import Style
from rich.status import Status
from octosuite.api.cache import cache
from octosuite.api.models import User, Repo, Org, Search
from octosuite.app.lib import (
check_updates,
preview_response,
export_response,
set_menu_title,
)
from octosuite.app.lib import console, clear_screen, ascii_banner
from .dialogs import Dialogs
from .prompts import Prompts
from .._lib import check_updates, preview_response, export_response, set_menu_title
from .._lib import console, clear_screen, ascii_banner
from ..core.cache import cache
from ..core.models import User, Repo, Org, Search
CUSTOM_STYLE = Style(
[

4
src/octosuite/meta.py Normal file
View File

@@ -0,0 +1,4 @@
from importlib.metadata import version
__pkg__ = "octosuite"
__version__ = version(__pkg__)

10
uv.lock generated
View File

@@ -144,7 +144,6 @@ name = "octosuite"
version = "5.0.0"
source = { editable = "." }
dependencies = [
{ name = "black" },
{ name = "pyfiglet" },
{ name = "questionary" },
{ name = "rich" },
@@ -158,11 +157,10 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "black", specifier = ">=25.12.0" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=26.1.0" },
{ name = "pyfiglet", specifier = ">=1.0.4" },
{ name = "questionary", specifier = ">=2.1.1" },
{ name = "rich", specifier = ">=14.2.0" },
{ name = "rich", specifier = ">=14.3.3" },
{ name = "update-checker", specifier = ">=0.18.0" },
]
provides-extras = ["dev"]
@@ -262,15 +260,15 @@ wheels = [
[[package]]
name = "rich"
version = "14.2.0"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]