mirror of
https://github.com/bellingcat/octosuite.git
synced 2026-06-07 19:08:36 +03:00
feat: add cli. tui can be run with -t/--tui
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
4
src/octosuite/app/__init__.py
Normal file
4
src/octosuite/app/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import cli, tui
|
||||
from ..meta import __pkg__, __version__
|
||||
|
||||
__all__ = ["cli", "tui", "__pkg__", "__version__"]
|
||||
3
src/octosuite/app/cli/__init__.py
Normal file
3
src/octosuite/app/cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .main import run_cli, arg_parser
|
||||
|
||||
__all__ = ["arg_parser", "run_cli"]
|
||||
@@ -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]")
|
||||
@@ -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
15
src/octosuite/app/main.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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
4
src/octosuite/meta.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from importlib.metadata import version
|
||||
|
||||
__pkg__ = "octosuite"
|
||||
__version__ = version(__pkg__)
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user