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

View File

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

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

View File

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

View File

@@ -5,12 +5,17 @@ import questionary as q
from questionary import Style from questionary import Style
from rich.status import Status 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 .dialogs import Dialogs
from .prompts import Prompts 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( 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" version = "5.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "black" },
{ name = "pyfiglet" }, { name = "pyfiglet" },
{ name = "questionary" }, { name = "questionary" },
{ name = "rich" }, { name = "rich" },
@@ -158,11 +157,10 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "black", specifier = ">=25.12.0" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=26.1.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=26.1.0" },
{ name = "pyfiglet", specifier = ">=1.0.4" }, { name = "pyfiglet", specifier = ">=1.0.4" },
{ name = "questionary", specifier = ">=2.1.1" }, { 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" }, { name = "update-checker", specifier = ">=0.18.0" },
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
@@ -262,15 +260,15 @@ wheels = [
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.2.0" version = "14.3.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "pygments" }, { 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 = [ 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]] [[package]]