mirror of
https://github.com/bellingcat/octosuite.git
synced 2026-06-08 03:18:35 +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"
|
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
|
||||||
@@ -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__"]
|
|
||||||
|
|||||||
@@ -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 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"
|
||||||
|
|
||||||
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 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]")
|
||||||
@@ -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
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
|
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()
|
||||||
@@ -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
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"
|
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]]
|
||||||
|
|||||||
Reference in New Issue
Block a user