diff --git a/.dockerignore b/.dockerignore index a087df4..93b212e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ .github/ images/ *.md +!README.md # pyproject reads it during `pip install .`; Dockerfile COPYs it # Python cache __pycache__/ diff --git a/README.md b/README.md index 997e7fb..52753b4 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ students β€” all working **legally, on systems they own or are authorised to tes plans an objective step by step, then summarizes findings and drafts an engagement report. Bring your own key or run a local model β€” nothing auto-executes and nothing is fabricated. -- **πŸ—‚ 215 curated tools, one console** β€” install and run across 21 categories without - hunting down Git repos; a fixed tag taxonomy (63 tags in use) makes every tool - discoverable. +- **πŸ—‚ 215 curated tools, one interactive console** β€” type to search every tool, then + install and run across 21 categories without hunting down Git repos; a fixed tag + taxonomy (63 tags in use) makes every tool discoverable. - **πŸ”Ž It knows what it doesn't have** β€” `/find` searches your catalog first, then the GitHub API, and shows real maintained projects with the reason each was ranked. - **πŸ›‘ Safe by default** β€” standard installs, **no `curl | bash`**, downloads pinned + @@ -89,9 +89,9 @@ students β€” all working **legally, on systems they own or are authorised to tes forensics/IR β€” all on **authorized targets only**.
-hackingtool console: banner with live system readout and the / command palette +hackingtool interactive TUI: categories with live tool counts, type to search all tools, / opens the command palette
-The console on launch β€” live system readout, and / opens the command palette. +The interactive console on launch β€” browse categories, type to search every tool, and / opens the command palette.
--- @@ -263,7 +263,13 @@ Some individual tools need a language runtime to install/run; the core app doesn ## Quick Commands -Launch `hackingtool` and type. There are only three kinds of input: +`hackingtool` opens an **interactive terminal UI**: browse the categories, **type to +search every tool** across all of them, `↑↓` to move, `Enter` to open, `←`/`Esc` to go +back, and `/` for the command palette (`/config`, `/update`, `/menu`, `/quit`). When a +newer release is out it shows an *update available* line on launch, and `/update` +detects how you installed hackingtool and shows the exact upgrade command to run. + +For a keyboard-driven REPL instead, `hackingtool --classic` takes three kinds of input: | You type | It means | Example | |---|---|---| @@ -311,9 +317,9 @@ Inside a tool: `1` install Β· `2` run Β· `c` ask for the exact command for your /help β€” the same card, in the app. -On a non-interactive terminal (or without `prompt_toolkit`) hackingtool falls back to -the classic numbered menu, where `/` or `s` searches, `t` filters by tag, `r` or `a` -recommends, `?` helps and `q` quits. Force it with `hackingtool --classic`. +The command set above is the `--classic` REPL. On a non-interactive terminal hackingtool +falls back to the classic numbered menu, where `/` or `s` searches, `t` filters by tag, +`r` or `a` recommends, `?` helps and `q` quits. > **New here?** [docs/HOW-TO-USE.md](docs/HOW-TO-USE.md) walks through each of these > start to finish with numbered steps. @@ -416,9 +422,10 @@ No tmux? It says so and opens the tool inline instead; disable it entirely with ### βš™ Settings and the AI layer -`/config` opens a full-screen settings editor (`↑↓` move, `←→` change, `Enter` edit, -`t` test the connection, `Esc` close); `/config ` sets one key from the -prompt. Settings live in `~/.hackingtool/config.json`. +`/config` opens a searchable settings screen β€” type to filter, `↑↓` move, `Enter` cycles +a choice or edits a value (auto-saved), `←`/`Esc` back; in `--classic` it's a full-screen +editor with `t` to test the AI connection. `/config ` sets one key directly. +Settings live in `~/.hackingtool/config.json`. The AI layer is **opt-in and bring-your-own-key**: an OpenAI-compatible endpoint when `ai_base_url` + an API key are set, else a local [Ollama](https://ollama.com), else diff --git a/images/screenshots/1.png b/images/screenshots/1.png index b49b8ef..c015a9d 100644 Binary files a/images/screenshots/1.png and b/images/screenshots/1.png differ diff --git a/pyproject.toml b/pyproject.toml index 5be9895..4714423 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" # floor: code uses 3.10 union syntax license = { text = "MIT" } authors = [{ name = "Z4nzu" }] keywords = ["security", "pentesting", "hacking", "recon", "osint", "cli"] -dependencies = ["rich>=13.0.0", "pyyaml>=6.0", "platformdirs>=4.0", "prompt_toolkit>=3.0.0", "python-dotenv>=1.0.0"] +dependencies = ["rich>=13.0.0", "pyyaml>=6.0", "platformdirs>=4.0", "prompt_toolkit>=3.0.0", "python-dotenv>=1.0.0", "textual>=8.2"] classifiers = [ "Development Status :: 4 - Beta", @@ -50,7 +50,7 @@ dev = ["pytest>=8", "ruff>=0.6"] where = ["src"] [tool.setuptools.package-data] -hackingtool = ["catalog/*.yaml", "pipelines/*.yaml", "skill/*.md"] +hackingtool = ["catalog/*.yaml", "pipelines/*.yaml", "skill/*.md", "tui/*.tcss", "sponsors.yaml"] [tool.setuptools.dynamic] version = { attr = "hackingtool.constants.VERSION" } diff --git a/scripts/discover_tools.py b/scripts/discover_tools.py index 9aeef8e..58b78d6 100644 --- a/scripts/discover_tools.py +++ b/scripts/discover_tools.py @@ -48,7 +48,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from audit_tools import ROOT, gh_repo, load_token, parse_tools # noqa: E402 sys.path.insert(0, str(ROOT)) -from tags import TAXONOMY # noqa: E402 +from hackingtool.tags import TAXONOMY # noqa: E402 CATALOG_DIR = ROOT / "catalog" CACHE_FILE = ROOT / "scripts" / ".discover_cache.json" diff --git a/scripts/gen_catalog_owners.py b/scripts/gen_catalog_owners.py index 61ec2c8..46027d9 100644 --- a/scripts/gen_catalog_owners.py +++ b/scripts/gen_catalog_owners.py @@ -17,7 +17,7 @@ OUT = SRC / "catalog_owners.py" _URL = re.compile(r"(? set[str]: def _parse_tags(reply: str | None) -> list[str]: """Pull a JSON array of strings from a model reply; keep only real tags.""" - if not reply: + if not isinstance(reply, str) or not reply: # None / non-str content β†’ no tags return [] m = re.search(r"\[.*\]", reply, re.DOTALL) if not m: @@ -80,7 +80,8 @@ def _byo_key(prompt: str) -> str | None: try: with urllib.request.urlopen(req, timeout=60) as resp: data = json.loads(resp.read()) - return data["choices"][0]["message"]["content"] + content = data["choices"][0]["message"]["content"] + return content if isinstance(content, str) else None # null/tool-call content β†’ None except (urllib.error.URLError, OSError, ValueError, KeyError, IndexError): return None @@ -105,7 +106,16 @@ def test_connection() -> tuple[bool, str]: provider = config.ai_provider() use_byo = provider == "openai-compat" or ( provider == "auto" and bool(config.ai_base_url()) and bool(config.ai_key())) - return _probe_byo() if use_byo else _probe_ollama() + if not use_byo: + return _probe_ollama() + ok, detail = _probe_byo() + if ok or provider != "auto": + return ok, detail + # auto mode mirrors ask(): BYO failed β†’ fall back to local Ollama before reporting failure + ok2, detail2 = _probe_ollama() + if ok2: + return True, f"{detail2} (remote failed: {detail})" + return False, f"{detail}; Ollama fallback also failed: {detail2}" def _probe_byo() -> tuple[bool, str]: @@ -123,8 +133,10 @@ def _probe_byo() -> tuple[bool, str]: "Content-Type": "application/json", "Authorization": f"Bearer {key}"}) try: with urllib.request.urlopen(req, timeout=30) as resp: - reply = json.loads(resp.read())["choices"][0]["message"]["content"].strip() - return True, f"{model} replied {reply!r}" + content = json.loads(resp.read())["choices"][0]["message"]["content"] + if not isinstance(content, str): # null / tool-call content isn't a usable reply + return False, f"unexpected response from {url} β€” content was {type(content).__name__}, not text" + return True, f"{model} replied {content.strip()!r}" except urllib.error.HTTPError as e: return False, f"HTTP {e.code} from {url} β€” {e.read().decode(errors='replace')[:200]}" except (urllib.error.URLError, OSError) as e: diff --git a/src/hackingtool/ai_report.py b/src/hackingtool/ai_report.py index 702dd0a..4f87a63 100644 --- a/src/hackingtool/ai_report.py +++ b/src/hackingtool/ai_report.py @@ -120,7 +120,7 @@ def draft_report(e: Engagement) -> Path | None: out = (f"# Engagement: {e.name} β€” Report (AI DRAFT)\n\n{banner}\n" f"{narrative}\n\n---\n\n## Appendix β€” Verified findings\n\n" - f"{render_report(e)}\n") + f"{render_report(e, findings)}\n") e.report_draft_file.write_text(out) return e.report_draft_file diff --git a/src/hackingtool/cli.py b/src/hackingtool/cli.py index 299f71b..2a49194 100755 --- a/src/hackingtool/cli.py +++ b/src/hackingtool/cli.py @@ -33,6 +33,7 @@ from rich.columns import Columns from hackingtool.core import HackingToolsCollection, ask, clear_screen, console from hackingtool.constants import VERSION_DISPLAY, REPO_WEB_URL, USER_CONFIG_DIR +from hackingtool import update_check from hackingtool.config import get_tools_dir import hackingtool.ai_recommend as ai_recommend from hackingtool.tools.anonsurf import AnonSurfTools @@ -294,6 +295,13 @@ def _build_header() -> Panel: body.add_row(Text(" ⚠ For authorized security testing only", style="bold dim red")) + # Cached once-a-day check; prints nothing when up to date, offline, or opted out. + if (newer := update_check.latest_if_newer()): + body.add_row(Text( + f" ⬆ update available: v{newer} (you have {VERSION_DISPLAY}) Β· " + f"{REPO_WEB_URL}/releases", + style="bold yellow")) + return Panel( body, title=f"[bold bright_magenta][ HackingTool {VERSION_DISPLAY} ][/bold bright_magenta]", @@ -409,14 +417,14 @@ def _tool_table(matches, title): for i, (tool, cat) in enumerate(matches, start=1): status = "[green]βœ”[/green]" if tool.is_installed else "[dim]✘[/dim]" table.add_row(str(i), status, tool.TITLE, cat) - table.add_row("99", "", "Back to main menu", "") + table.add_row("b", "", "Back to main menu", "") console.print(table) def _pick_tool(matches): - """Prompt for a number and open that tool; ignore blank/99/invalid.""" + """Prompt for a number and open that tool; ignore blank/back/invalid.""" raw = ask("[bold cyan]>[/bold cyan]", default="").strip() - if not raw or raw == "99": + if not raw or raw.lower() in ("b", "back"): return try: idx = int(raw) @@ -583,7 +591,7 @@ def search_tools(query: str | None = None): desc = (tool.DESCRIPTION or "β€”").splitlines()[0] table.add_row(str(i), tool.TITLE, cat, desc) - table.add_row("99", "Back to main menu", "", "") + table.add_row("b", "Back to main menu", "", "") console.print(table) _pick_tool(matches) @@ -805,11 +813,13 @@ def main(): from hackingtool import prompt prompt.FORCE_CLASSIC = force_classic - if argv and (argv[0] in ("-h", "--help") or any(a.startswith("--") for a in argv)): - args = _build_arg_parser().parse_args(argv) # argparse prints help and exits on -h/--help + if argv: + # Any argument means headless mode. argparse rejects unknown/typo args with + # exit 2 (instead of silently opening the menu); -h/--help exits 0. + parser = _build_arg_parser() + args = parser.parse_args(argv) if not args.engagement: - console.print("[bold red]--engagement is required in headless mode.[/bold red]") - return + parser.error("--engagement is required in headless mode") # prints usage, exits 2 _run_headless(args) return try: @@ -831,9 +841,15 @@ def main(): from hackingtool import config config.ensure_user_files() # first run: scaffold config.json + commented .env - from hackingtool import repl - if repl._use_repl(force_classic): - repl.run_repl() + if force_classic: + from hackingtool import repl + if repl._use_repl(force_classic): + repl.run_repl() + else: + interact_menu() + elif sys.stdin.isatty() and sys.stdout.isatty(): + from hackingtool.tui import app as tui_app + tui_app.run() else: interact_menu() diff --git a/src/hackingtool/config.py b/src/hackingtool/config.py index d628283..33b01dd 100644 --- a/src/hackingtool/config.py +++ b/src/hackingtool/config.py @@ -31,17 +31,19 @@ def load() -> dict[str, Any]: """Load config from disk, merging with defaults for any missing keys.""" if USER_CONFIG_FILE.exists(): try: - on_disk = json.loads(USER_CONFIG_FILE.read_text()) + on_disk = json.loads(USER_CONFIG_FILE.read_text(encoding="utf-8")) + if not isinstance(on_disk, dict): # e.g. [], "x", 42 β€” not a config object + raise ValueError("config.json is not a JSON object") return {**DEFAULT_CONFIG, **on_disk} - except (json.JSONDecodeError, OSError) as exc: + except (ValueError, OSError) as exc: # ValueError covers JSONDecodeError logger.warning("Config file unreadable (%s), using defaults.", exc) return dict(DEFAULT_CONFIG) def save(cfg: dict[str, Any]) -> None: - """Write config to disk, creating parent directories if needed.""" - USER_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - USER_CONFIG_FILE.write_text(json.dumps(cfg, indent=2, sort_keys=True)) + """Write config to disk atomically, creating parent directories if needed.""" + from hackingtool.storage import write_json + write_json(USER_CONFIG_FILE, cfg, indent=2, sort_keys=True) _ENV_TEMPLATE = """\ @@ -74,7 +76,7 @@ def ensure_user_files() -> None: save(dict(DEFAULT_CONFIG)) env_file = USER_CONFIG_FILE.parent / ".env" if not env_file.exists(): - env_file.write_text(_ENV_TEMPLATE) + env_file.write_text(_ENV_TEMPLATE, encoding="utf-8") try: env_file.chmod(0o600) # secrets file β€” owner-only except OSError: @@ -106,7 +108,7 @@ _ENUMS = { # fixed string domains "ai_provider": {"auto", "ollama", "openai-compat"}, "theme": set(THEME_CHOICES), # applies on next launch } -_BOOLS = {"show_archived"} # boolean-valued keys +_BOOLS = {"show_archived", "update_check"} # boolean-valued keys _TRUE = {"true", "on", "yes", "1"} _FALSE = {"false", "off", "no", "0"} @@ -171,7 +173,10 @@ def set_value(key: str, value: str) -> tuple[bool, str]: coerced = low cfg = load() cfg[resolved] = coerced - save(cfg) + try: + save(cfg) + except OSError as exc: + return False, f"Could not save configuration: {exc}" return True, f"set {resolved} = {coerced}" @@ -211,16 +216,19 @@ def set_ai_key(value: str) -> tuple[bool, str]: if "\n" in value or "\r" in value: return False, "API key can't contain newlines." env_file = USER_CONFIG_FILE.parent / ".env" - env_file.parent.mkdir(parents=True, exist_ok=True) - lines = env_file.read_text().splitlines() if env_file.exists() else _ENV_TEMPLATE.splitlines() new_line = f"HACKINGTOOL_AI_KEY={value}" if value else "# HACKINGTOOL_AI_KEY=sk-ant-your-key-here" - for i, line in enumerate(lines): - if line.lstrip("# ").startswith("HACKINGTOOL_AI_KEY="): - lines[i] = new_line - break - else: - lines.append(new_line) - env_file.write_text("\n".join(lines) + "\n") + try: + env_file.parent.mkdir(parents=True, exist_ok=True) + lines = env_file.read_text(encoding="utf-8").splitlines() if env_file.exists() else _ENV_TEMPLATE.splitlines() + for i, line in enumerate(lines): + if line.lstrip("# ").startswith("HACKINGTOOL_AI_KEY="): + lines[i] = new_line + break + else: + lines.append(new_line) + env_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + except (OSError, UnicodeError) as exc: + return False, f"Could not save API key: {type(exc).__name__}" # never echo the secret try: env_file.chmod(0o600) # secrets file β€” owner-only except OSError: diff --git a/src/hackingtool/config_ui.py b/src/hackingtool/config_ui.py index e966e17..46a4e41 100644 --- a/src/hackingtool/config_ui.py +++ b/src/hackingtool/config_ui.py @@ -12,6 +12,9 @@ gates on ``prompt._use_pt()`` and falls back to the read-only table. """ from __future__ import annotations +import asyncio +import threading + from hackingtool import config _PAGE = 5 # PgUp/PgDn jump size @@ -204,12 +207,37 @@ def open_editor() -> None: state["msg"] = "cancelled" e.app.layout.focus(body) + probing = {"on": False} + @kb.add("t", filter=nav) def _(e): - from hackingtool import ai_recommend - ok, detail = ai_recommend.test_connection() # blocks briefly; manual action - mark = "βœ“ " if ok else "βœ— " - state["msg"] = mark + (detail[:70] + "…" if len(detail) > 70 else detail) + # AI probe can block up to 30s on the network β€” run it off the UI loop so + # Escape/Ctrl-C stay responsive. Ignore repeat presses while one is in flight. + if probing["on"]: + return + probing["on"] = True + state["msg"] = "testing AI connection…" + app = e.app + loop = asyncio.get_running_loop() # key handlers run on the app's event loop + + def worker(): + from hackingtool import ai_recommend + try: + ok, detail = ai_recommend.test_connection() + msg = ("βœ“ " if ok else "βœ— ") + (detail[:70] + "…" if len(detail) > 70 else detail) + except Exception as exc: + msg = f"βœ— probe error: {exc}" + + def done(): + probing["on"] = False + state["msg"] = msg + app.invalidate() + try: + loop.call_soon_threadsafe(done) + except RuntimeError: # loop already closed (app exited mid-probe) + probing["on"] = False + + threading.Thread(target=worker, daemon=True).start() @kb.add("escape", filter=nav, eager=True) @kb.add("c-c", filter=nav) diff --git a/src/hackingtool/constants.py b/src/hackingtool/constants.py index 5a2d240..e5ffa4a 100644 --- a/src/hackingtool/constants.py +++ b/src/hackingtool/constants.py @@ -12,6 +12,11 @@ REPO_WEB_URL = f"https://github.com/{REPO_OWNER}/{REPO_NAME}" VERSION = "3.0.0" VERSION_DISPLAY = f"v{VERSION}" +# Maintainer-owned sponsor feed (fixed in code, NOT a user setting). Blank = committed +# sponsors.yaml only, ZERO network. A maintainer sets this + cuts a release to enable +# remote refresh; end users never point this anywhere. +SPONSORS_URL = "" + # ── Python requirement ──────────────────────────────────────────────────────── MIN_PYTHON = (3, 10) @@ -58,10 +63,11 @@ def _configured_theme() -> str: config-module import cycle). Unknown/missing β†’ 'magenta'.""" try: import json - name = json.loads(USER_CONFIG_FILE.read_text()).get("theme") + payload = json.loads(USER_CONFIG_FILE.read_text(encoding="utf-8")) + name = payload.get("theme") if isinstance(payload, dict) else None except (OSError, ValueError): name = None - return name if name in _PALETTES else "magenta" + return name if isinstance(name, str) and name in _PALETTES else "magenta" # THEME_HEX drives the prompt_toolkit REPL surface (prompt + rule); the rich @@ -80,6 +86,7 @@ DEFAULT_CONFIG: dict = { "version": VERSION, "theme": "magenta", "show_archived": False, + "update_check": True, # check GitHub once/day for a newer release on startup "background_runner": "auto", # "auto" (on iff tmux present) | "off" "ai_provider": "auto", # "auto" (BYO-key if set, else Ollama) | "ollama" | "openai-compat" "ai_model": "llama3", # model name sent to the transport diff --git a/src/hackingtool/core.py b/src/hackingtool/core.py index 6cd3a43..5aa2ab4 100644 --- a/src/hackingtool/core.py +++ b/src/hackingtool/core.py @@ -447,7 +447,7 @@ class HackingTool: if self.before_uninstall(): if isinstance(self.UNINSTALL_COMMANDS, (list, tuple)): for cmd in self.UNINSTALL_COMMANDS: - console.print(f"[error]β†’ {cmd}[/error]") + console.print(f"[warning]β†’ {cmd}[/warning]") _run_shell(cmd) self.after_uninstall() diff --git a/src/hackingtool/discover.py b/src/hackingtool/discover.py index 960ea54..7b6c4d1 100644 --- a/src/hackingtool/discover.py +++ b/src/hackingtool/discover.py @@ -604,7 +604,7 @@ def save_repo(repo: Repo, tags: list[str]) -> Path | None: path = _found_path() try: - data = yaml.safe_load(path.read_text()) or {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except (OSError, yaml.YAMLError): data = {} if not isinstance(data, dict): @@ -628,8 +628,8 @@ def save_repo(repo: Repo, tags: list[str]) -> Path | None: "discovered": True, }) try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(data, sort_keys=False)) + from hackingtool.storage import write_text + write_text(path, yaml.safe_dump(data, sort_keys=False)) # atomic: never truncate saved discoveries except OSError: return None return path diff --git a/src/hackingtool/engagement.py b/src/hackingtool/engagement.py index cf3b0f7..227743f 100644 --- a/src/hackingtool/engagement.py +++ b/src/hackingtool/engagement.py @@ -66,16 +66,15 @@ class Engagement: self.save() def save(self) -> None: - self.workspace.mkdir(parents=True, exist_ok=True) - (self.workspace / "engagement.json").write_text( - json.dumps(asdict(self), indent=2)) + from hackingtool.storage import write_json + write_json(self.workspace / "engagement.json", asdict(self), indent=2) def load(name: str) -> Engagement | None: f = ENGAGEMENTS_ROOT / name / "engagement.json" if not f.exists(): return None - return Engagement(**json.loads(f.read_text())) + return Engagement(**json.loads(f.read_text(encoding="utf-8"))) def create(name: str, targets: list[str] | None = None, diff --git a/src/hackingtool/findings.py b/src/hackingtool/findings.py index 9fa3641..9f61d10 100644 --- a/src/hackingtool/findings.py +++ b/src/hackingtool/findings.py @@ -33,17 +33,20 @@ def parse_httpx(raw: str, ts: str) -> tuple[list[Finding], list[str]]: obj = json.loads(ln) except json.JSONDecodeError: continue # skip malformed line, keep going - url = obj.get("url") or obj.get("input") or "" - if not url: + if not isinstance(obj, dict): # valid JSON but not an object β†’ skip continue + url = obj.get("url") or obj.get("input") or "" + if not isinstance(url, str) or not url: + continue + title = obj.get("title") + title = title if isinstance(title, str) and title else url details = { "status_code": obj.get("status_code"), "title": obj.get("title"), "tech": obj.get("tech"), "webserver": obj.get("webserver"), } - findings.append(Finding("service", url, obj.get("title") or url, - "info", "httpx", details, ln, ts)) + findings.append(Finding("service", url, title, "info", "httpx", details, ln, ts)) urls.append(url) return findings, urls @@ -58,13 +61,23 @@ def parse_nuclei(raw: str, ts: str) -> tuple[list[Finding], list[str]]: obj = json.loads(ln) except json.JSONDecodeError: continue - info = obj.get("info") or {} + if not isinstance(obj, dict): # valid JSON but not an object β†’ skip + continue + info = obj.get("info") + if not isinstance(info, dict): + info = {} target = obj.get("matched-at") or obj.get("host") or "" + if not isinstance(target, str): + target = "" tid = obj.get("template-id", "") + tid = tid if isinstance(tid, str) else "" + name = info.get("name") or tid + name = name if isinstance(name, str) and name else tid + sev = info.get("severity") + sev = sev if isinstance(sev, str) and sev else "unknown" details = {"template_id": tid, "matched_at": obj.get("matched-at"), "type": obj.get("type")} - findings.append(Finding("vulnerability", target, info.get("name") or tid, - info.get("severity") or "unknown", "nuclei", + findings.append(Finding("vulnerability", target, name, sev, "nuclei", details, ln, ts)) return findings, [] @@ -73,11 +86,11 @@ PARSERS = {"subfinder": parse_subfinder, "httpx": parse_httpx, "nuclei": parse_n def save_findings(path: Path, findings: list[Finding]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps([asdict(f) for f in findings], indent=2)) + from hackingtool.storage import write_json + write_json(path, [asdict(f) for f in findings], indent=2) def load_findings(path: Path) -> list[Finding]: if not path.exists(): return [] - return [Finding(**d) for d in json.loads(path.read_text())] + return [Finding(**d) for d in json.loads(path.read_text(encoding="utf-8"))] diff --git a/src/hackingtool/orchestrator.py b/src/hackingtool/orchestrator.py index fc2dcb7..98c2411 100644 --- a/src/hackingtool/orchestrator.py +++ b/src/hackingtool/orchestrator.py @@ -41,16 +41,27 @@ def run_pipeline(e: Engagement, pipeline_name: str = "recon") -> list[findings_m stdin = "\n".join(e.targets if step["input"] == "targets" else forward) try: proc = subprocess.run([tool, *step["args"]], input=stdin, - capture_output=True, text=True, timeout=STEP_TIMEOUT) + capture_output=True, text=True, timeout=STEP_TIMEOUT, + encoding="utf-8", errors="replace") # a stray non-UTF8 byte must not abort the run raw = proc.stdout if proc.returncode != 0: tail = (proc.stderr or "").strip().splitlines()[-3:] e.log(f"{tool}: exit {proc.returncode}; stderr: {' | '.join(tail)}") - except (subprocess.TimeoutExpired, OSError) as exc: + except subprocess.TimeoutExpired as exc: + raw = exc.stdout or "" # keep records emitted before the deadline + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + (e.workspace / step["output"]).write_text(raw, encoding="utf-8") + parsed, _ = findings_mod.PARSERS[step["parser"]](raw, ts) + all_findings.extend(parsed) + e.log(f"timeout {tool} after {STEP_TIMEOUT}s; salvaged {len(parsed)} finding(s)") + forward = [] # incomplete step β†’ don't forward to the next + continue + except OSError as exc: e.log(f"error {tool}: {exc}") forward = [] continue - (e.workspace / step["output"]).write_text(raw) + (e.workspace / step["output"]).write_text(raw, encoding="utf-8") parsed, forward = findings_mod.PARSERS[step["parser"]](raw, ts) if e.scope_out: kept = [] diff --git a/src/hackingtool/prompt.py b/src/hackingtool/prompt.py index e2c4ddb..e913c41 100644 --- a/src/hackingtool/prompt.py +++ b/src/hackingtool/prompt.py @@ -124,6 +124,9 @@ def dispatch(raw, ctx): if raw.startswith("/"): parts = raw[1:].split(maxsplit=1) + if not parts: # bare "/" (or just whitespace) β€” show help, don't crash + cli.show_help() + return CONTINUE cmd = parts[0].lower() arg = parts[1].strip() if len(parts) > 1 else "" if cmd in ("quit", "q", "exit"): @@ -262,8 +265,12 @@ def _run_command(arg, ctx): cwd = tool._get_tool_dir() or str(config.get_tools_dir()) command = arg if arg != first else None # bare tool name β†’ prepared shell only - resolved = session.run(_slug(tool.TITLE), cwd, command=command, - banner=_usage_banner(tool)) + try: + resolved = session.run(_slug(tool.TITLE), cwd, command=command, + banner=_usage_banner(tool)) + except session.SessionError as exc: + cli.console.print(f"[red]✘ Could not start background pane: {exc}[/red]") + return CONTINUE cli.console.print( f"[green]β–Ά started '{resolved}' in background β€” /attach to view[/green]") return CONTINUE diff --git a/src/hackingtool/registry.py b/src/hackingtool/registry.py index 4d430fe..4039f79 100644 --- a/src/hackingtool/registry.py +++ b/src/hackingtool/registry.py @@ -176,7 +176,7 @@ def _load_file(path: Path, inert: bool = False): """Return (Category | None, overlays list). A file may add new tools, overlay existing ones by title, or both. ``inert`` strips executable keys (see _EXECUTABLE_KEYS) β€” used for user-supplied catalogs.""" - data = yaml.safe_load(path.read_text()) or {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} header = data.get("category") entries = [e for e in (data.get("tools") or []) if isinstance(e, dict)] if inert: diff --git a/src/hackingtool/repl.py b/src/hackingtool/repl.py index a995377..fbb8200 100644 --- a/src/hackingtool/repl.py +++ b/src/hackingtool/repl.py @@ -86,7 +86,7 @@ class HTCompleter: def _resolve(name, tools_by_title): - """Case-insensitive lookup with a fuzzy fallback (difflib, 0.6 cutoff).""" + """Exact title, then canonical binary name (SYSTEM_PKGS['which']), then fuzzy (difflib).""" import difflib name = name.strip() @@ -95,6 +95,12 @@ def _resolve(name, tools_by_title): lower = {t.lower(): tool for t, tool in tools_by_title.items()} if name.lower() in lower: return lower[name.lower()] + # canonical binary alias: `@nmap` β†’ the tool whose `which` is nmap, before fuzzy + # matching can mis-hit (e.g. nmap/sqlmap β†’ "NoSqlMap"). Only when unambiguous. + canon = [tool for tool in tools_by_title.values() + if str((getattr(tool, "SYSTEM_PKGS", None) or {}).get("which") or "").lower() == name.lower()] + if len(canon) == 1: + return canon[0] hit = difflib.get_close_matches(name.lower(), lower.keys(), n=1, cutoff=0.6) return lower[hit[0]] if hit else None diff --git a/src/hackingtool/report.py b/src/hackingtool/report.py index 5d9bd5c..16d013c 100644 --- a/src/hackingtool/report.py +++ b/src/hackingtool/report.py @@ -13,14 +13,16 @@ def _cell(s: str) -> str: return str(s).replace("|", "\\|").replace("\n", " ") -def render_report(e: Engagement) -> str: +def render_report(e: Engagement, findings=None) -> str: """Deterministic facts-table report as a Markdown string (no file write). The single source of report truth: findings/severities/targets/tools come straight from ``findings.json``. AI4 reuses this verbatim as its verified - appendix so the model never has to emit the facts. + appendix so the model never has to emit the facts. Pass ``findings`` to reuse an + already-loaded snapshot (keeps narrative and appendix consistent); else re-reads. """ - findings = load_findings(e.findings_file) + if findings is None: + findings = load_findings(e.findings_file) lines: list[str] = [f"# Engagement: {e.name}", ""] lines.append(f"- **Targets:** {', '.join(e.targets) or '(none)'}") lines.append(f"- **Scope in:** {', '.join(e.scope_in) or '(none)'}") diff --git a/src/hackingtool/session.py b/src/hackingtool/session.py index 3b530bb..5a5ffd2 100644 --- a/src/hackingtool/session.py +++ b/src/hackingtool/session.py @@ -37,7 +37,11 @@ def _run(args: list[str], capture: bool = False) -> subprocess.CompletedProcess: ["tmux", *args], capture_output=capture, text=True, check=False, ) except FileNotFoundError: - return subprocess.CompletedProcess(["tmux", *args], 1, "", "") + return subprocess.CompletedProcess(["tmux", *args], 1, "", "tmux executable not found") + + +class SessionError(RuntimeError): + """A background tmux window/session could not be created or fed its command.""" def _has_session() -> bool: @@ -85,14 +89,18 @@ def run(label: str, cwd: str, command: str | None = None, """ label = _unique_label(label) if _has_session(): - _run(["new-window", "-t", SESSION, "-n", label, "-c", cwd]) + res = _run(["new-window", "-t", SESSION, "-n", label, "-c", cwd], capture=True) else: - _run(["new-session", "-d", "-s", SESSION, "-n", label, "-c", cwd]) + res = _run(["new-session", "-d", "-s", SESSION, "-n", label, "-c", cwd], capture=True) + if res.returncode != 0: + raise SessionError((res.stderr or "").strip() or "tmux could not create the window") target = f"{SESSION}:{label}" if banner: _run(["send-keys", "-t", target, f"# {banner}", "Enter"]) if command: - _run(["send-keys", "-t", target, command, "Enter"]) + res = _run(["send-keys", "-t", target, command, "Enter"], capture=True) + if res.returncode != 0: + raise SessionError((res.stderr or "").strip() or "tmux could not deliver the command") return label diff --git a/src/hackingtool/skill.py b/src/hackingtool/skill.py index 1f3ad9e..024fb12 100644 --- a/src/hackingtool/skill.py +++ b/src/hackingtool/skill.py @@ -134,7 +134,8 @@ def sanitize(finding) -> dict: def wrap_untrusted(text: str) -> str: """Frame untrusted tool output in the one delimiter used everywhere.""" - return f"\n{text}\n" + safe = (text or "").replace("", "<\\/scan_data>") # can't break out of the fence + return f"\n{safe}\n" def demo() -> None: diff --git a/src/hackingtool/sponsors.py b/src/hackingtool/sponsors.py new file mode 100644 index 0000000..60f0632 --- /dev/null +++ b/src/hackingtool/sponsors.py @@ -0,0 +1,131 @@ +"""Sponsor list for the banner: committed baseline + optional cached-daily remote merge. Never raises.""" +import json +import os +import random +import time +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from .constants import REPO_NAME, SPONSORS_URL, USER_CONFIG_DIR, VERSION + +_YAML = Path(__file__).resolve().parent / "sponsors.yaml" +_CACHE = USER_CONFIG_DIR / "sponsors_cache.json" +_TTL = 86400 # refresh remote at most once/day; otherwise serve the cache +_TIERS = {"gold", "silver", "bronze"} +_WEIGHTS = {"gold": 3, "silver": 2, "bronze": 1} +_CTA = "https://github.com/sponsors/Z4nzu" + +_featured_memo: list["Sponsor"] | None = None # pick once per process; banner re-renders a lot + + +@dataclass +class Sponsor: + name: str + url: str + tagline: str = "" + tier: str = "bronze" + + +def _coerce(entry: dict) -> Sponsor | None: + """One raw yaml/json entry -> Sponsor, or None if untrusted/incomplete. Never raises.""" + try: + name, url = entry.get("name"), entry.get("url") + if not name or not isinstance(url, str) or not url.startswith(("http://", "https://")): + return None + tier = entry.get("tier", "bronze") + return Sponsor(str(name), url, + str(entry.get("tagline") or ""), + tier if tier in _TIERS else "bronze") + except Exception: + return None + + +def _committed() -> list[Sponsor]: + try: + raw = (yaml.safe_load(_YAML.read_text()) or {}).get("sponsors") or [] + return [s for s in (_coerce(e) for e in raw) if s] + except Exception: + return [] + + +def _sponsors_url() -> str: + return SPONSORS_URL or "" # fixed in code (constants.SPONSORS_URL), not user-editable + + +def _fetch(url: str, timeout: float) -> list: + req = urllib.request.Request(url, headers={"User-Agent": f"{REPO_NAME}/{VERSION}"}) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (operator-configured URL) + return json.load(r).get("sponsors") or [] + + +def _cached_remote(url: str, timeout: float) -> list: + """Fresh cache -> serve it; else fetch and rewrite; offline/error -> stale cache for this url, else [].""" + cached: list = [] + try: + data = json.loads(_CACHE.read_text()) + if data.get("url") == url: + cached = data.get("sponsors") or [] + if time.time() - data.get("ts", 0) < _TTL: + return cached + except Exception: + cached = [] + try: + sponsors = _fetch(url, timeout) + except Exception: + return cached + try: + _CACHE.parent.mkdir(parents=True, exist_ok=True) + _CACHE.write_text(json.dumps({"ts": time.time(), "url": url, "sponsors": sponsors})) + except Exception: + pass + return sponsors + + +def _remote() -> list[Sponsor]: + """Remote entries, or [] when opted out / no sponsors_url (the default -> ZERO network). Never raises.""" + if os.environ.get("HACKINGTOOL_NO_SPONSOR_FETCH"): + return [] + url = _sponsors_url() + if not url: + return [] + try: + raw = _cached_remote(url, 2.0) + except Exception: + return [] + return [s for s in (_coerce(e) for e in raw) if s] + + +def load() -> list[Sponsor]: + """Committed sponsors, then any remote ones (deduped by url). Never raises -> committed-only, else [].""" + committed = _committed() + try: + seen = {s.url for s in committed} + return committed + [s for s in _remote() if s.url not in seen] + except Exception: + return committed + + +def featured(n: int = 1, seed: int | None = None) -> list[Sponsor]: + """Up to n sponsors, tier-weighted, sampled without replacement. Memoized per process (seed=None); seed is for tests.""" + global _featured_memo + if seed is None and _featured_memo is not None: + return _featured_memo + pool = list(load()) # copy: sampling pops from it + rng = random.Random(seed) + weights = [_WEIGHTS.get(s.tier, 1) for s in pool] + picked: list[Sponsor] = [] + for _ in range(min(n, len(pool))): + i = rng.choices(range(len(pool)), weights=weights, k=1)[0] + picked.append(pool.pop(i)) + weights.pop(i) + if seed is None: + _featured_memo = picked + return picked + + +def sponsor_cta() -> str: + """The 'become a sponsor' URL.""" + return _CTA diff --git a/src/hackingtool/sponsors.yaml b/src/hackingtool/sponsors.yaml new file mode 100644 index 0000000..02900aa --- /dev/null +++ b/src/hackingtool/sponsors.yaml @@ -0,0 +1,9 @@ +# Sponsors shown in the hackingtool banner. +# Add yours via a pull request, or sponsor the project: https://github.com/sponsors/Z4nzu +# Each entry: name (required), url (required, http/https), tagline (optional), tier (gold|silver|bronze, default bronze). +sponsors: [] +# Example: +# - name: "Acme Security" +# url: "https://acme.example" +# tagline: "Cloud pentest platform" +# tier: "gold" diff --git a/src/hackingtool/storage.py b/src/hackingtool/storage.py new file mode 100644 index 0000000..0b22013 --- /dev/null +++ b/src/hackingtool/storage.py @@ -0,0 +1,29 @@ +"""Atomic JSON writes: a failed/interrupted write must never truncate a good file.""" +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +def write_text(path: Path, text: str) -> None: + """Write text to path atomically: temp file in the same dir, fsync, os.replace.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) # atomic on POSIX; prior file survives any failure above + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def write_json(path: Path, data: Any, **dumps_kwargs) -> None: + """Serialize data to path atomically.""" + write_text(path, json.dumps(data, **dumps_kwargs)) diff --git a/src/hackingtool/tools/information_gathering.py b/src/hackingtool/tools/information_gathering.py index f5f2e97..8d50be9 100644 --- a/src/hackingtool/tools/information_gathering.py +++ b/src/hackingtool/tools/information_gathering.py @@ -48,7 +48,7 @@ class PortScan(HackingTool): def run(self): clear_screen() console.print(Panel(Text(self.TITLE, justify="center"), style="bold magenta")) - target = Prompt.ask("[bold]Select a Target IP[/bold magenta]", default="", show_default=False) + target = Prompt.ask("[bold magenta]Select a Target IP[/bold magenta]", default="", show_default=False) subprocess.run(["sudo", "nmap", "-O", "-Pn", target]) diff --git a/src/hackingtool/tools/tool_manager.py b/src/hackingtool/tools/tool_manager.py index 54d3393..7a9f424 100644 --- a/src/hackingtool/tools/tool_manager.py +++ b/src/hackingtool/tools/tool_manager.py @@ -36,7 +36,7 @@ class UpdateTool(HackingTool): console.print(" pipx [success]pipx upgrade hackingtool[/success]") console.print(" pip [success]pip install --upgrade hackingtool[/success]") console.print(" .deb download the latest .deb, then [success]sudo apt install ./.deb[/success]") - console.print(" docker [success]docker pull ghcr.io/z4nzu/hackingtool:latest[/success]") + console.print(" docker [success]docker pull hardikzinzu/hackingtool:latest[/success]") console.print(f"[dim]Latest release: {REPO_WEB_URL}/releases/latest[/dim]") @@ -58,7 +58,7 @@ class UninstallTool(HackingTool): console.print(" pipx [success]pipx uninstall hackingtool[/success]") console.print(" pip [success]pip uninstall hackingtool[/success]") console.print(" .deb [success]sudo apt remove python3-hackingtool[/success]") - console.print(" docker [success]docker rmi ghcr.io/z4nzu/hackingtool:latest[/success]") + console.print(" docker [success]docker rmi hardikzinzu/hackingtool:latest[/success]") if USER_CONFIG_DIR.exists() and Confirm.ask( f"Remove your data + installed tools at {USER_CONFIG_DIR} now?", default=False): diff --git a/src/hackingtool/tools/xss_attack.py b/src/hackingtool/tools/xss_attack.py index 039ee65..19d72fd 100644 --- a/src/hackingtool/tools/xss_attack.py +++ b/src/hackingtool/tools/xss_attack.py @@ -143,7 +143,7 @@ class RVuln(HackingTool): "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh;" "source $HOME/.cargo/env;" "sudo apt install librust-openssl-dev;" - "cd RVuln;sudo su;cargo build --release;mv target/release/RVuln" + "cd RVuln;cargo build --release;mv target/release/RVuln" ] RUN_COMMANDS = ["RVuln"] PROJECT_URL = "https://github.com/iinc0gnit0/RVuln" diff --git a/src/hackingtool/tui/__init__.py b/src/hackingtool/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hackingtool/tui/app.py b/src/hackingtool/tui/app.py new file mode 100644 index 0000000..b468036 --- /dev/null +++ b/src/hackingtool/tui/app.py @@ -0,0 +1,43 @@ +"""HackingToolApp: boots to MainScreen; the bottom prompt's "/" opens a floating CommandPalette.""" +from pathlib import Path + +from textual.app import App +from textual.binding import Binding + +from hackingtool.tui.screens.main import MainScreen +from hackingtool.tui.screens.config import ConfigScreen +from hackingtool.tui.widgets.command_palette import CommandPalette + + +class HackingToolApp(App): + CSS_PATH = Path(__file__).with_name("app.tcss") + BINDINGS = [Binding("q", "quit", "Quit")] + + def on_mount(self) -> None: + self.push_screen(MainScreen()) + + def on_command_palette_selected(self, event: CommandPalette.Selected) -> None: + # close the palette + clear the prompt (covers mouse-click, which doesn't submit) + for pal in self.screen.query(CommandPalette): + pal.remove() + for prompt in self.screen.query("#ht-prompt"): + prompt.value = "" + if event.command == "/quit": + self.exit() + elif event.command == "/menu": + while not isinstance(self.screen, MainScreen): + self.pop_screen() + elif event.command == "/config": + if not isinstance(self.screen, ConfigScreen): + self.push_screen(ConfigScreen()) + elif event.command == "/update": + from hackingtool import update_check + from hackingtool.tui.widgets.update_modal import UpdateModal + self.push_screen(UpdateModal(update_check.latest_if_newer())) + else: # /recommend, /report β€” Phase 2; tell the user instead of doing nothing + self.notify(f"{event.command} is coming in a future release.", + title="Not yet available", severity="warning") + + +def run() -> None: + HackingToolApp().run() diff --git a/src/hackingtool/tui/app.tcss b/src/hackingtool/tui/app.tcss new file mode 100644 index 0000000..876d745 --- /dev/null +++ b/src/hackingtool/tui/app.tcss @@ -0,0 +1,61 @@ +/* src/hackingtool/tui/app.tcss β€” single source of visual style (claude-* look) */ +Screen { background: $surface; } + +#ht-header { + dock: top; + height: auto; + padding: 0 1; + color: $text; + border-bottom: solid $panel; +} + +#ht-banner { dock: top; height: auto; padding: 0 1; } +#ht-logo { border: round $accent; width: auto; height: auto; padding: 0 1; color: $text; text-style: bold; } + +OptionList { border: round $panel; padding: 0 1; } +OptionList > .option-list--option-highlighted { background: $boost; color: $text; } + +/* list fills the middle and scrolls within itself; prompt pinned at the bottom */ +#main-cats, #cat-tools { height: 1fr; } +#ht-prompt { dock: bottom; height: 3; border: round $panel; padding: 0 1; } + +#ht-hint { color: $text-muted; height: 1; padding: 0 1; } + +UpdateModal { align: center middle; } +UpdateModal > #update-box { + width: 80%; + max-width: 90; + border: round $warning; + padding: 1 2; + background: $panel; +} + +EditModal { align: center middle; } +EditModal > #edit-box { + width: 70%; + border: round $accent; + padding: 1 2; + background: $panel; +} + +PermissionModal { align: center middle; } +PermissionModal > #perm-box { + width: 70%; + border: round $warning; + padding: 1 2; + background: $panel; +} + +/* Floating slash-command palette: overlay lifts it above the 3-row bottom prompt + without growing the screen (pattern verified from Textual's Select). */ +CommandPalette { + overlay: screen; + constrain: none inside; + max-height: 12; + dock: bottom; + offset-y: -3; + width: 1fr; + border: round $accent; + background: $panel; + padding: 0 1; +} diff --git a/src/hackingtool/tui/data.py b/src/hackingtool/tui/data.py new file mode 100644 index 0000000..2394f1b --- /dev/null +++ b/src/hackingtool/tui/data.py @@ -0,0 +1,87 @@ +"""Read-only view of the engine's categories/tools for the TUI. No Textual here.""" +from dataclasses import dataclass + + +@dataclass +class CatRow: + index: int # position in cli.all_tools + title: str + icon: str + label: str + count: int # active, OS-compatible tools + + +@dataclass +class ToolRow: + index: int # position within the category's active tool list + title: str + description: str + url: str + installed: bool + obj: object # live HackingTool instance (for install/run/uninstall) + + +def _engine(): + import hackingtool.cli as cli + return cli.all_tools, cli.tool_definitions + + +def categories() -> list[CatRow]: + all_tools, defs = _engine() + rows = [] + for i, (coll, (title, icon, label)) in enumerate(zip(all_tools, defs)): + active = coll._active_tools() if hasattr(coll, "_active_tools") else list(coll.TOOLS) + rows.append(CatRow(index=i, title=title, icon=icon, label=label, count=len(active))) + return rows + + +def _active_tools(cat_index: int) -> list: + all_tools, _ = _engine() + coll = all_tools[cat_index] + return coll._active_tools() if hasattr(coll, "_active_tools") else list(coll.TOOLS) + + +def tools_in(cat_index: int) -> list[ToolRow]: + rows = [] + for j, t in enumerate(_active_tools(cat_index)): + try: + installed = bool(t.is_installed()) + except Exception: + installed = False + rows.append(ToolRow( + index=j, + title=getattr(t, "TITLE", "?"), + description=getattr(t, "DESCRIPTION", "") or "", + url=getattr(t, "PROJECT_URL", "") or "", + installed=installed, + obj=t, + )) + return rows + + +def tool_at(cat_index: int, tool_index: int) -> ToolRow: + return tools_in(cat_index)[tool_index] + + +@dataclass +class ToolHit: + cat_index: int + tool_index: int + title: str + cat_label: str + + +def search_tools(needle: str) -> list[ToolHit]: + """All tools (across every category) whose title OR category name matches. + Title-only read β€” no is_installed() probing, so main-screen search stays fast.""" + n = needle.lower().strip() + if not n: + return [] + hits: list[ToolHit] = [] + for cat in categories(): + cat_hit = n in cat.label.lower() + for j, t in enumerate(_active_tools(cat.index)): + title = getattr(t, "TITLE", "?") + if cat_hit or n in title.lower(): + hits.append(ToolHit(cat.index, j, title, cat.label)) + return hits diff --git a/src/hackingtool/tui/glyphs.py b/src/hackingtool/tui/glyphs.py new file mode 100644 index 0000000..ee2e8b4 --- /dev/null +++ b/src/hackingtool/tui/glyphs.py @@ -0,0 +1,8 @@ +"""Single source of truth for TUI glyphs (mirrors the claude-* component look).""" +PROMPT = "❯" +RUN = "⏺" +SUB = "⎿" +STATUS = "β—†" +INSTALLED = "βœ”" +NOT_INSTALLED = "β—‹" +LAB = "lab" diff --git a/src/hackingtool/tui/screens/__init__.py b/src/hackingtool/tui/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hackingtool/tui/screens/base.py b/src/hackingtool/tui/screens/base.py new file mode 100644 index 0000000..13e822b --- /dev/null +++ b/src/hackingtool/tui/screens/base.py @@ -0,0 +1,120 @@ +"""Base screen: header docked top, a filterable OptionList filling the middle, prompt docked bottom.""" +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Input, OptionList, Static +from textual.widgets.option_list import Option + +from hackingtool.constants import VERSION_DISPLAY +from hackingtool.tui import data, glyphs +from hackingtool.tui.widgets.command_palette import CommandPalette +from hackingtool.tui.widgets.header import Header + + +class HTScreen(Screen): + LIST_ID = "ht-list" # subclass overrides + PROMPT_PLACEHOLDER = "type to filter…" # subclass overrides + SHOW_BACK = True # MainScreen (the root) sets False + + def compose(self) -> ComposeResult: + yield self.header_widget() + yield from self.content() + + def content(self) -> ComposeResult: + ol = OptionList(*self.options(""), id=self.LIST_ID) + ol.can_focus = False # keep focus on the prompt so typing always lands there + yield ol + yield Static(self.hint_text(), id="ht-hint") + prompt = Input(placeholder=self.PROMPT_PLACEHOLDER, id="ht-prompt") + prompt.border_title = glyphs.PROMPT + yield prompt + + def hint_text(self) -> str: + back = " Β· ← esc back" if self.SHOW_BACK else "" + return f" type to filter Β· / commands Β· ↑↓ move Β· ⏎ open{back} Β· shift+drag copy" + + def on_mount(self) -> None: + # Highlight the first row and focus the prompt so ↑↓/⏎ and typing work at once. + if self.query("#ht-prompt"): + ol = self._list() + if ol.option_count: + ol.highlighted = 0 + self.query_one("#ht-prompt", Input).focus() + + # hooks + def options(self, needle: str) -> list[Option]: + return [] + + def open_selected(self, option_id: str) -> None: + ... + + def _list(self) -> OptionList: + return self.query_one(f"#{self.LIST_ID}", OptionList) + + # slash command palette (lazy-mounted on the first "/") + def _palette(self) -> CommandPalette | None: + pals = self.query(CommandPalette) + return pals.first() if pals else None + + def _ensure_palette(self) -> CommandPalette: + pal = self._palette() + if pal is None: + pal = CommandPalette() + self.mount(pal) + return pal + + def _remove_palette(self) -> None: + pal = self._palette() + if pal is not None: + pal.remove() + + def header_widget(self): + total = sum(c.count for c in data.categories()) + return Header(f"hackingtool {VERSION_DISPLAY}", self.status_text(), total) + + def status_text(self) -> str: + return "ready" + + def on_input_changed(self, event: Input.Changed) -> None: + v = event.value + if v.startswith("/"): + self._ensure_palette().filter(v[1:]) + else: + self._remove_palette() + ol = self._list() + ol.clear_options() + ol.add_options(self.options(v)) + ol.highlighted = 0 if ol.option_count else None + + def on_key(self, event) -> None: + if event.key == "escape": + if self._palette() is not None: + self.query_one("#ht-prompt", Input).value = "" # clears β†’ removes palette + event.stop() + return + # ← goes back when the prompt is empty; with text it stays cursor-left (Input binds it). + if event.key == "left" and isinstance(self.focused, Input): + if not self.focused.value and self._palette() is None and self.SHOW_BACK: + self.app.pop_screen() + event.stop() + return + # Input binds neither up nor down (verified), so they bubble here. + if event.key in ("down", "up") and isinstance(self.focused, Input): + pal = self._palette() + target = pal if pal is not None else self._list() + if target.option_count: + (target.action_cursor_down if event.key == "down" else target.action_cursor_up)() + event.stop() + + def on_input_submitted(self, event: Input.Submitted) -> None: + pal = self._palette() + if pal is not None: + if pal.highlighted is not None: + self.post_message(CommandPalette.Selected(pal.get_option_at_index(pal.highlighted).id)) + self.query_one("#ht-prompt", Input).value = "" # clears β†’ removes palette + return + ol = self._list() + if ol.highlighted is not None: + self.open_selected(ol.get_option_at_index(ol.highlighted).id) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + self.open_selected(event.option.id) diff --git a/src/hackingtool/tui/screens/category.py b/src/hackingtool/tui/screens/category.py new file mode 100644 index 0000000..93efd4b --- /dev/null +++ b/src/hackingtool/tui/screens/category.py @@ -0,0 +1,29 @@ +"""Category screen: tools in a category with install marks; the bottom prompt filters them.""" +from textual.widgets.option_list import Option + +from hackingtool.tui import glyphs, data +from hackingtool.tui.screens.base import HTScreen +from hackingtool.tui.screens.tool import ToolScreen + + +class CategoryScreen(HTScreen): + BINDINGS = [("escape", "app.pop_screen", "Back")] + LIST_ID = "cat-tools" + PROMPT_PLACEHOLDER = "type to filter tools…" + + def __init__(self, cat_index: int): + self._cat = cat_index + self._rows = data.tools_in(cat_index) + super().__init__() + + def options(self, needle: str = "") -> list[Option]: + opts = [] + for r in self._rows: + if needle and needle.lower() not in r.title.lower(): + continue + mark = glyphs.INSTALLED if r.installed else glyphs.NOT_INSTALLED + opts.append(Option(f"{mark} {r.title}", id=str(r.index))) + return opts + + def open_selected(self, option_id: str) -> None: + self.app.push_screen(ToolScreen(data.tool_at(self._cat, int(option_id)))) diff --git a/src/hackingtool/tui/screens/config.py b/src/hackingtool/tui/screens/config.py new file mode 100644 index 0000000..0d18ca6 --- /dev/null +++ b/src/hackingtool/tui/screens/config.py @@ -0,0 +1,68 @@ +"""Native settings screen: filter rows, ⏎ cycles choices / edits text, auto-saved via config.set_value.""" +from textual.widgets.option_list import Option +from textual import work + +from hackingtool import config, config_ui +from hackingtool.tui.screens.base import HTScreen +from hackingtool.tui.widgets.edit_modal import EditModal + + +class ConfigScreen(HTScreen): + LIST_ID = "config-rows" + PROMPT_PLACEHOLDER = "search settings…" + + def _pretty(self, key: str) -> str: + return key.replace("_", " ").capitalize() + + def _value_cell(self, row: dict) -> str: + v = row["value"] or "(unset)" + if row["kind"] == "choice": + return f"β€Ή {v} β€Ί" + if row["kind"] == "readonly": + return f"{v} (read-only)" + return f"{v} (⏎ edit)" # text / secret + + def options(self, needle: str = "") -> list[Option]: + n = needle.lower().strip() + out = [] + for row in config_ui._rows(): + label = self._pretty(row["key"]) + if n and n not in label.lower() and n not in row["key"].lower(): + continue + out.append(Option(f" {label:<24}{self._value_cell(row)}", id=row["key"])) + return out + + def open_selected(self, key: str) -> None: + row = next((r for r in config_ui._rows() if r["key"] == key), None) + if row is None: + return + kind = row["kind"] + if kind == "choice": + nv = config_ui._cycle_choice(row["value"], row["choices"], +1) + ok, msg = config.set_value(key, nv) + if not ok: + self.app.notify(msg, title="Invalid", severity="error") + self._refresh() + elif kind in ("text", "secret"): + self._edit(key, row["value"], kind == "secret") + else: # readonly + self.app.notify(f"{self._pretty(key)} is read-only.", severity="warning") + + @work + async def _edit(self, key: str, current: str, secret: bool) -> None: + new = await self.app.push_screen_wait(EditModal(key, current, secret)) + if new is None: + return + ok, msg = (config.set_ai_key(new) if secret else config.set_value(key, new)) + self.app.notify(msg, title="Saved" if ok else "Rejected", + severity="information" if ok else "error") + self._refresh() + + def _refresh(self) -> None: + ol = self._list() + needle = self.query_one("#ht-prompt").value + idx = ol.highlighted + ol.clear_options() + ol.add_options(self.options(needle)) + if ol.option_count: + ol.highlighted = min(idx or 0, ol.option_count - 1) diff --git a/src/hackingtool/tui/screens/main.py b/src/hackingtool/tui/screens/main.py new file mode 100644 index 0000000..8c54080 --- /dev/null +++ b/src/hackingtool/tui/screens/main.py @@ -0,0 +1,38 @@ +"""Main screen: all categories with icon + count; the bottom prompt filters them.""" +from textual.widgets.option_list import Option + +from hackingtool.tui import data, glyphs +from hackingtool.tui.screens.base import HTScreen +from hackingtool.tui.screens.category import CategoryScreen +from hackingtool.tui.screens.tool import ToolScreen +from hackingtool.tui.widgets.banner import SponsorBanner + + +class MainScreen(HTScreen): + LIST_ID = "main-cats" + PROMPT_PLACEHOLDER = "type to search all tools…" + SHOW_BACK = False # root screen β€” nothing to go back to + + def __init__(self): + self._cats = data.categories() + super().__init__() + + def header_widget(self): + total = sum(c.count for c in self._cats) + return SponsorBanner(total) + + def options(self, needle: str = "") -> list[Option]: + # Empty prompt browses categories; typing searches every tool in every submenu. + if not needle.strip(): + return [Option(f"{c.icon} {c.label} {c.count}", id=str(c.index)) + for c in self._cats] + return [Option(f"{glyphs.RUN} {h.title} Β· {h.cat_label}", + id=f"t:{h.cat_index}:{h.tool_index}") + for h in data.search_tools(needle)] + + def open_selected(self, option_id: str) -> None: + if option_id.startswith("t:"): + _, cat, tool = option_id.split(":") + self.app.push_screen(ToolScreen(data.tool_at(int(cat), int(tool)))) + else: + self.app.push_screen(CategoryScreen(int(option_id))) diff --git a/src/hackingtool/tui/screens/tool.py b/src/hackingtool/tui/screens/tool.py new file mode 100644 index 0000000..19a9f9f --- /dev/null +++ b/src/hackingtool/tui/screens/tool.py @@ -0,0 +1,73 @@ +"""Tool detail screen: gated Run/Reinstall/Uninstall drop to a real TTY via App.suspend().""" +from textual.app import ComposeResult +from textual.widgets import OptionList, Static +from textual.widgets.option_list import Option +from textual import work + +from hackingtool.tui import glyphs, working +from hackingtool.tui.data import ToolRow +from hackingtool.tui.screens.base import HTScreen +from hackingtool.tui.widgets.permission import PermissionModal + +_GATED = ("run", "install", "uninstall") + + +class ToolScreen(HTScreen): + BINDINGS = [("escape", "app.pop_screen", "Back"), ("left", "app.pop_screen", "Back")] + + def __init__(self, row: ToolRow): + self._row = row + super().__init__() + + def _detail_text(self) -> str: + r = self._row + state = f"{glyphs.INSTALLED} installed" if r.installed else f"{glyphs.NOT_INSTALLED} not installed" + return f" {r.title} {state}\n {r.description}\n {r.url}" + + def content(self) -> ComposeResult: + yield Static(self._detail_text(), id="tool-detail") + yield OptionList( + Option(f"{glyphs.RUN} Run", id="run"), + Option("↻ Install", id="install"), + Option("⧉ Open repo", id="open"), + Option("⌫ Uninstall", id="uninstall"), + Option("← Back", id="back"), + id="tool-actions", + ) + yield Static(f" {glyphs.PROMPT} ↑↓ move Β· ⏎ select Β· ← esc back", id="ht-hint") + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + action = event.option.id + if action == "back": + self.app.pop_screen() + elif action == "open": + self._open_repo() + elif action in _GATED: + self._run_action(action) # fire the worker; must NOT await push_screen_wait here + + def _open_repo(self) -> None: + import webbrowser + url = (self._row.url or "").strip() + if url.lower().startswith(("http://", "https://")): + webbrowser.open_new_tab(url) + + @work(exit_on_error=False) + async def _run_action(self, action: str) -> None: + answer = await self.app.push_screen_wait(PermissionModal(f"{action} {self._row.title}?")) + if answer == "no": + return + fn = getattr(self._row.obj, action) + with self.app.suspend(): # drop to raw terminal so the tool gets a TTY + try: + print(working.line(f"running {self._row.title}")) + fn() + except SystemExit: + pass # a tool quitting itself must not quit the TUI + except BaseException: + self.app.bell() + # reflect the tool's new install state in the detail line + try: + self._row.installed = bool(self._row.obj.is_installed()) + except Exception: + pass + self.query_one("#tool-detail", Static).update(self._detail_text()) diff --git a/src/hackingtool/tui/widgets/__init__.py b/src/hackingtool/tui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hackingtool/tui/widgets/banner.py b/src/hackingtool/tui/widgets/banner.py new file mode 100644 index 0000000..5e72559 --- /dev/null +++ b/src/hackingtool/tui/widgets/banner.py @@ -0,0 +1,82 @@ +"""Compact header for the MAIN screen: boxed wordmark + product line + sponsor/CTA.""" +from rich.text import Text +from textual import work +from textual.containers import Vertical +from textual.widgets import Static + +from hackingtool import sponsors, update_check +from hackingtool.constants import REPO_WEB_URL, VERSION_DISPLAY +from hackingtool.tui import glyphs + +# Big block wordmark (ANSI Shadow, pyfiglet) β€” "HACKINGTOOL" on one row of letters. +# The box is a CSS border (#ht-logo), not chars. Twin of cli._BANNER_ART (which stacks it). +_LOGO = "\n".join([ + "β–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—", + "β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘", + "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘", + "β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘", + "β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—", + "β•šβ•β• β•šβ•β•β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β•β•šβ•β• β•šβ•β•β•šβ•β•β•šβ•β• β•šβ•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•", +]) + + +class SponsorBanner(Vertical): + """Boxed wordmark + product line + sponsor CTA for the main screen. (claude-header)""" + + def __init__(self, tool_count: int): + self._count = tool_count + super().__init__(id="ht-banner") + + def compose(self): + yield Static(self._logo_text(), id="ht-logo", markup=False) + yield Static(self._tagline_text(), id="ht-tagline", markup=False) + yield Static(self._sponsor_text(), id="ht-sponsor", markup=False) + yield Static("", id="ht-update", markup=False) # filled iff a newer release exists + + def on_mount(self) -> None: + self._check_update() # background: never blocks first render on the network + + @work(thread=True, exit_on_error=False) + def _check_update(self) -> None: + newer = update_check.latest_if_newer() # cached daily, offline-safe, opt-out honored + if newer: + self.app.call_from_thread(self._show_update, newer) + + def _show_update(self, newer: str) -> None: + url = f"{REPO_WEB_URL}/releases" + t = Text(f" {glyphs.STATUS} update available: v{newer} " + f"(you have {VERSION_DISPLAY}) Β· type /update Β· ", style="bold yellow") + t.append(url, style=f"yellow link {url}") + self.query_one("#ht-update", Static).update(t) + + def _logo_text(self) -> Text: + return Text(_LOGO, style="bold bright_green") + + def _tagline_text(self) -> Text: + t = Text(f" All-in-One pentest toolkit Β· {VERSION_DISPLAY} Β· " + f"{self._count} tools Β· ") + t.append(f"{glyphs.STATUS} ready", style="bold yellow") + t.append(" Β· ") + t.append(REPO_WEB_URL, style=f"dim link {REPO_WEB_URL}") + return t + + def _sponsor_text(self) -> Text: + cta = sponsors.sponsor_cta() + try: + featured = sponsors.featured(1) + except Exception: + featured = [] + t = Text() + if featured: + s = featured[0] + t.append(f" {glyphs.STATUS} Sponsored by ", style="bold") + t.append(f"{s.name} β†’ {s.url}", style=f"bold cyan link {s.url}") + if s.tagline: + t.append(f" Β· {s.tagline}", style="dim") + t.append(" Β· ✦ Sponsor hackingtool β†’ ", style="dim") + t.append(cta, style=f"dim link {cta}") + else: + t.append(" ✦ Sponsor hackingtool Β· prime placement, seen every launch β†’ ", + style="dim") + t.append(cta, style=f"dim link {cta}") + return t diff --git a/src/hackingtool/tui/widgets/command_palette.py b/src/hackingtool/tui/widgets/command_palette.py new file mode 100644 index 0000000..79a73d4 --- /dev/null +++ b/src/hackingtool/tui/widgets/command_palette.py @@ -0,0 +1,45 @@ +from textual.message import Message +from textual.widgets import OptionList +from textual.widgets.option_list import Option + +# (command, help) β€” /config/menu/quit act now; /recommend,/report are Phase-2 (notify). +# No /search: just type in the prompt to filter live. +COMMANDS = [ + ("/config", "edit settings"), + ("/update", "check for & how to update hackingtool"), + ("/recommend", "AI tool recommendation"), + ("/report", "export findings"), + ("/menu", "browse categories"), + ("/quit", "exit hackingtool"), +] + + +class CommandPalette(OptionList): + """Floating slash-command palette, filtered by the bottom prompt. (claude-slash-menu)""" + + class Selected(Message): + def __init__(self, command: str) -> None: + self.command = command + super().__init__() + + def __init__(self): + super().__init__(*self._opts("")) + self.can_focus = False # prompt keeps focus; ↑↓ are routed here from it + + def _opts(self, needle: str) -> list[Option]: + n = needle.lower() + return [ + Option(f"{cmd} {help_}", id=cmd) + for cmd, help_ in COMMANDS + if not n or n in cmd.lower() or n in help_.lower() + ] + + def filter(self, needle: str) -> None: + self.clear_options() + self.add_options(self._opts(needle)) + if self.option_count: + self.highlighted = 0 + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: # mouse click + event.stop() + self.post_message(self.Selected(event.option.id)) diff --git a/src/hackingtool/tui/widgets/edit_modal.py b/src/hackingtool/tui/widgets/edit_modal.py new file mode 100644 index 0000000..c9644c8 --- /dev/null +++ b/src/hackingtool/tui/widgets/edit_modal.py @@ -0,0 +1,32 @@ +"""Inline value editor for free-text / secret config rows. Returns the new string, or None.""" +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Static + + +class EditModal(ModalScreen[str | None]): + BINDINGS = [("escape", "cancel", "Cancel")] + + def __init__(self, key: str, current: str = "", secret: bool = False): + self._key = key + self._current = current + self._secret = secret + super().__init__() + + def compose(self) -> ComposeResult: + with Vertical(id="edit-box"): + yield Static(f"● Set {self._key.replace('_', ' ')}") + value = "" if self._secret else self._current + inp = Input(value=value, password=self._secret, id="edit-input") + inp.border_subtitle = "⏎ save Β· esc cancel" + yield inp + + def on_mount(self) -> None: + self.query_one("#edit-input", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + self.dismiss(event.value) + + def action_cancel(self) -> None: + self.dismiss(None) diff --git a/src/hackingtool/tui/widgets/header.py b/src/hackingtool/tui/widgets/header.py new file mode 100644 index 0000000..bfde2b3 --- /dev/null +++ b/src/hackingtool/tui/widgets/header.py @@ -0,0 +1,21 @@ +from rich.text import Text +from textual.widgets import Static + +from hackingtool.tui import glyphs + + +class Header(Static): + """Top banner: title Β· status Β· tool count. (claude-header)""" + + def __init__(self, title: str, status: str, tool_count: int): + self._title = title + self._status = status + self._count = tool_count + super().__init__(id="ht-header") + + def render(self) -> Text: + t = Text() + t.append(f" {self._title} ") + t.append(f"{glyphs.STATUS} {self._status}", style="bold yellow") + t.append(f" {self._count} tools", style="dim") + return t diff --git a/src/hackingtool/tui/widgets/permission.py b/src/hackingtool/tui/widgets/permission.py new file mode 100644 index 0000000..d2e2429 --- /dev/null +++ b/src/hackingtool/tui/widgets/permission.py @@ -0,0 +1,29 @@ +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import RadioButton, RadioSet, Static + + +class PermissionModal(ModalScreen[str]): + """Confirm before a sudo / target-touching action. Returns 'yes'|'no'. (claude-permission)""" + + BINDINGS = [("escape", "cancel", "Cancel")] + _VALUES = ["yes", "no"] + + def __init__(self, message: str): + self._message = message + super().__init__() + + def compose(self) -> ComposeResult: + with Vertical(id="perm-box"): + yield Static(f"● Permission required\n{self._message}") + yield RadioSet( + RadioButton("Yes"), + RadioButton("No"), + ) + + def on_radio_set_changed(self, event: RadioSet.Changed) -> None: + self.dismiss(self._VALUES[event.radio_set.pressed_index]) + + def action_cancel(self) -> None: + self.dismiss("no") diff --git a/src/hackingtool/tui/widgets/update_modal.py b/src/hackingtool/tui/widgets/update_modal.py new file mode 100644 index 0000000..ecdd040 --- /dev/null +++ b/src/hackingtool/tui/widgets/update_modal.py @@ -0,0 +1,47 @@ +"""/update dialog: shows the detected install method's upgrade command (copyable). Never runs it.""" +from rich.text import Text +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Static + +from hackingtool import update_check +from hackingtool.constants import REPO_WEB_URL, VERSION_DISPLAY + + +class UpdateModal(ModalScreen): + BINDINGS = [("escape", "close", "Close"), ("enter", "copy", "Copy command")] + + def __init__(self, newer: str | None): + self._newer = newer + self._method, self._cmds = update_check.upgrade_commands() + super().__init__() + + def compose(self) -> ComposeResult: + with Vertical(id="update-box"): + yield Static(self._text(), markup=False) + + def _text(self) -> Text: + t = Text() + if self._newer: + t.append(f"● Update {VERSION_DISPLAY} β†’ v{self._newer}\n\n", style="bold yellow") + else: + t.append(f"● Up to date {VERSION_DISPLAY}\n\n", style="bold green") + t.append(f" Detected install: {self._method}\n\n", style="dim") + t.append(f" {self._cmds[self._method]}\n\n", style="bold cyan") + t.append(" Not right? Other methods:\n", style="dim") + for m, cmd in self._cmds.items(): + if m != self._method: + t.append(f" {cmd}\n", style="dim") + url = f"{REPO_WEB_URL}/releases" + t.append(f"\n {url}\n", style=f"dim link {url}") + t.append(" ⏎ copy command Β· esc close", style="dim") + return t + + def action_copy(self) -> None: + self.app.copy_to_clipboard(self._cmds[self._method]) + self.app.notify("Upgrade command copied to clipboard.", severity="information") + self.dismiss() + + def action_close(self) -> None: + self.dismiss() diff --git a/src/hackingtool/tui/working.py b/src/hackingtool/tui/working.py new file mode 100644 index 0000000..e9e11b4 --- /dev/null +++ b/src/hackingtool/tui/working.py @@ -0,0 +1,17 @@ +"""Random Claude-style 'working…' words shown when a tool launches.""" +import random + +from hackingtool.tui import glyphs + +WORDS = [ + "Conjuring", "Summoning", "Herding", "Probing", "Enumerating", "Brewing", + "Forging", "Scanning", "Unpacking", "Wrangling", "Assembling", "Hacking", + "Rummaging", "Sniffing", "Cranking", "Spelunking", "Decoding", "Marshalling", +] + + +def line(suffix: str = "") -> str: + """e.g. '⏺ Conjuring… running Nmap'. suffix appended after the ellipsis.""" + word = random.choice(WORDS) + tail = f" {suffix}" if suffix else "" + return f"{glyphs.RUN} {word}…{tail}" diff --git a/src/hackingtool/update_check.py b/src/hackingtool/update_check.py new file mode 100644 index 0000000..3ae2c50 --- /dev/null +++ b/src/hackingtool/update_check.py @@ -0,0 +1,104 @@ +"""Non-blocking 'is a newer release out?' check against GitHub Releases, cached daily.""" +import json +import os +import time +import urllib.request +from itertools import takewhile +from pathlib import Path + +from .constants import REPO_NAME, REPO_OWNER, USER_CONFIG_DIR, VERSION + +_CACHE = USER_CONFIG_DIR / "update_check.json" +_API = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest" +_TTL = 86400 # hit the network at most once a day; otherwise serve the cache + +_memo_done = False # process-lifetime memo: the header re-renders every keystroke +_memo_val: str | None = None + + +def _parse(v: str) -> tuple[int, int, int]: + """'v3.0.1' -> (3,0,1). Non-numeric parts (pre-release tags) degrade to 0, never raise.""" + nums = [] + for part in v.lstrip("vV").split(".")[:3]: + digits = "".join(takewhile(str.isdigit, part)) # "0-rc1" -> "0", not "01" + nums.append(int(digits) if digits else 0) + return tuple(nums + [0] * (3 - len(nums))) # type: ignore[return-value] + + +def _fetch_latest(timeout: float) -> str: + req = urllib.request.Request( + _API, + headers={"Accept": "application/vnd.github+json", + "User-Agent": f"{REPO_NAME}/{VERSION}"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (fixed https URL) + return json.load(r).get("tag_name") or "" + + +def _cached_latest(timeout: float) -> str: + try: + data = json.loads(_CACHE.read_text()) + if time.time() - data.get("ts", 0) < _TTL: + return data.get("latest", "") + except Exception: + pass + latest = _fetch_latest(timeout) + try: + _CACHE.parent.mkdir(parents=True, exist_ok=True) + _CACHE.write_text(json.dumps({"ts": time.time(), "latest": latest})) + except Exception: + pass + return latest + + +def latest_if_newer(timeout: float = 2.0) -> str | None: + """Latest release tag (without the 'v') if newer than the running one, else None. Never raises.""" + global _memo_done, _memo_val + if _memo_done: + return _memo_val + _memo_done = True + _memo_val = None + + if os.environ.get("HACKINGTOOL_NO_UPDATE_CHECK"): + return None + try: + from . import config + if not config.load().get("update_check", True): + return None + except Exception: + pass + + try: + latest = _cached_latest(timeout) + if latest and _parse(latest) > _parse(VERSION): + _memo_val = latest.lstrip("vV") + except Exception: + _memo_val = None + return _memo_val + + +def install_method() -> str: + """Best-effort guess at how hackingtool was installed, for the /update hint.""" + p = str(Path(__file__).resolve()) + if Path("/.dockerenv").exists(): + return "docker" + if "/pipx/" in p: + return "pipx" + if (Path(__file__).resolve().parents[2] / ".git").exists(): + return "source" + if "dist-packages" in p: + return "apt" + return "pip" + + +def upgrade_commands() -> tuple[str, dict[str, str]]: + """(detected_method, {method: upgrade command}). The dialog shows all; detection just picks the default.""" + repo = Path(__file__).resolve().parents[2] + cmds = { + "pipx": f"pipx upgrade {REPO_NAME}", + "pip": f"pip install -U {REPO_NAME}", + "apt": f"sudo apt install --only-upgrade {REPO_NAME}", + "docker": f"docker pull hardikzinzu/{REPO_NAME}", + "source": f"git -C {repo} pull", + } + return install_method(), cmds diff --git a/tests/test_main_module.py b/tests/test_main_module.py new file mode 100644 index 0000000..dda58dd --- /dev/null +++ b/tests/test_main_module.py @@ -0,0 +1,9 @@ +"""`python -m hackingtool` dispatches to cli.main.""" +import runpy +from unittest import mock + + +def test_main_module_calls_cli_main(): + with mock.patch("hackingtool.cli.main") as m: + runpy.run_module("hackingtool", run_name="__main__") + assert m.called diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 8d138ab..570df8f 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -19,7 +19,7 @@ def test_run_pipeline_wires_stdin_and_normalizes(tmp_path, monkeypatch): monkeypatch.setattr(orchestrator.shutil, "which", lambda t: "/usr/bin/" + t) calls = {} - def fake_run(cmd, input=None, capture_output=None, text=None, timeout=None): + def fake_run(cmd, input=None, capture_output=None, text=None, timeout=None, **kwargs): tool = cmd[0] calls[tool] = input # capture what each step received on stdin out = { @@ -55,7 +55,7 @@ def test_scope_out_host_not_forwarded(tmp_path, monkeypatch): scope_in=["*.example.com"], scope_out=["admin.example.com"]) monkeypatch.setattr(orchestrator.shutil, "which", lambda t: "/usr/bin/" + t) captured = {} - def fake_run(cmd, input=None, capture_output=None, text=None, timeout=None): + def fake_run(cmd, input=None, capture_output=None, text=None, timeout=None, **kwargs): tool = cmd[0] captured[tool] = input out = { diff --git a/tests/test_pr_fixes.py b/tests/test_pr_fixes.py new file mode 100644 index 0000000..c4121ef --- /dev/null +++ b/tests/test_pr_fixes.py @@ -0,0 +1,97 @@ +"""Regression tests for the consolidated upstream-PR bug fixes (only the real issues).""" +import json + +import pytest + +from hackingtool import ai_recommend, config, constants, findings, repl, skill, storage + + +# ── #775 malformed config.json must not crash load / theme resolution ────────── +@pytest.mark.parametrize("payload", ["[]", '"x"', "42", "true", "null"]) +def test_malformed_config_falls_back_to_defaults(tmp_path, monkeypatch, payload): + f = tmp_path / "config.json" + f.write_text(payload, encoding="utf-8") + monkeypatch.setattr(config, "USER_CONFIG_FILE", f) + cfg = config.load() # must not raise + assert cfg["theme"] == constants.DEFAULT_CONFIG["theme"] + + +def test_configured_theme_survives_non_object_and_bad_value(tmp_path, monkeypatch): + f = tmp_path / "config.json" + monkeypatch.setattr(constants, "USER_CONFIG_FILE", f) + f.write_text("[]", encoding="utf-8") + assert constants._configured_theme() == "magenta" + f.write_text('{"theme": ["not-a-string"]}', encoding="utf-8") + assert constants._configured_theme() == "magenta" + + +# ── #768 parsers keep going past valid-JSON-but-non-object / wrong-typed lines ── +def test_parse_httpx_skips_non_object_and_nonstring_url(): + raw = "\n".join(['null', '[]', '5', '{"url": ["bad"]}', '{"url": "http://a", "title": 7}']) + got, urls = findings.parse_httpx(raw, "t") + assert urls == ["http://a"] + assert got[0].name == "http://a" # non-str title fell back to url, no crash + + +def test_parse_nuclei_skips_bad_records(): + raw = "\n".join(['null', '{"info": "oops"}', '{"matched-at": "h", "info": {"name": "x", "severity": "high"}}']) + got, _ = findings.parse_nuclei(raw, "t") + assert len(got) == 2 # the "info as string" line is kept (infoβ†’{}), null skipped + assert got[-1].severity == "high" + + +# ── #767 canonical binary name beats fuzzy mis-match ─────────────────────────── +def test_resolve_prefers_canonical_which(): + class _T: + def __init__(self, title, which=None): + self.TITLE = title + self.SYSTEM_PKGS = {"which": which} if which else {} + tools = {"Network Map (nmap)": _T("Network Map (nmap)", "nmap"), + "NoSqlMap": _T("NoSqlMap")} + assert repl._resolve("nmap", tools).TITLE == "Network Map (nmap)" + + +# ── #761 bare "/" must not crash the REPL ────────────────────────────────────── +def test_empty_slash_returns_continue(monkeypatch): + from hackingtool import prompt + monkeypatch.setattr("hackingtool.cli.show_help", lambda: None) + assert prompt.dispatch("/", prompt.PromptCtx("home")) is prompt.CONTINUE + + +# ── #763 unknown / incomplete CLI args exit non-zero instead of opening menu ─── +@pytest.mark.parametrize("argv", [["typo"], ["--report"], ["-z"]]) +def test_bad_cli_args_exit_nonzero(argv, monkeypatch): + from hackingtool import cli + monkeypatch.setattr("sys.argv", ["hackingtool", *argv]) + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code != 0 + + +# ── #778 null / non-str model content never reaches a .strip()/regex ─────────── +@pytest.mark.parametrize("reply", [None, 7, {"x": 1}, ["a"]]) +def test_parse_tags_rejects_non_str(reply): + assert ai_recommend._parse_tags(reply) == [] + + +# ── #757 untrusted fence can't be broken out of ─────────────────────────────── +def test_wrap_untrusted_neutralises_delimiter(): + out = skill.wrap_untrusted("evil injection") + assert out.count("") == 1 # only the real closing fence remains + + +# ── #773 atomic writes: a serialization failure never touches the good file ──── +def test_write_json_atomic_and_failure_preserving(tmp_path): + p = tmp_path / "d.json" + storage.write_json(p, {"a": 1}, indent=2) + assert json.loads(p.read_text(encoding="utf-8")) == {"a": 1} + with pytest.raises(TypeError): + storage.write_json(p, {"bad": {1, 2, 3}}) # sets aren't JSON-serializable + assert json.loads(p.read_text(encoding="utf-8")) == {"a": 1} # original intact + + +# ── #698 PortScan prompt markup is balanced (Rich would raise otherwise) ─────── +def test_portscan_prompt_markup_renders(): + import io + from rich.console import Console + Console(file=io.StringIO()).print("[bold magenta]Select a Target IP[/bold magenta]") diff --git a/tests/test_session.py b/tests/test_session.py index 3122e92..c119c5b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -123,11 +123,14 @@ def test_kill_window(rec): def test_no_crash_when_tmux_binary_missing(monkeypatch): """Verify session functions degrade gracefully when tmux binary is absent.""" + import pytest def boom(*a, **k): raise FileNotFoundError("tmux") monkeypatch.setattr(session.subprocess, "run", boom) monkeypatch.setattr(session, "windows", lambda: []) - # must not raise + # cleanup helpers still degrade silently session.kill("nmap") session.kill("all") - assert session.run("x", "/tmp") == "x" + # run() now SIGNALS the failure (caller reports it) instead of pretending it started + with pytest.raises(session.SessionError): + session.run("x", "/tmp") diff --git a/tests/test_sponsors.py b/tests/test_sponsors.py new file mode 100644 index 0000000..490629b --- /dev/null +++ b/tests/test_sponsors.py @@ -0,0 +1,82 @@ +"""sponsors: trust-filtered committed loader, tier-weighted pick, zero network by default. Never raises.""" +import hackingtool.sponsors as sp +from hackingtool.sponsors import Sponsor + + +def _reset(): + sp._featured_memo = None + + +def test_load_shipped_empty_returns_empty(monkeypatch): + _reset() + monkeypatch.setattr(sp, "_sponsors_url", lambda: "") # no remote, exercise shipped file + assert sp.load() == [] + + +def test_load_filters_untrusted_and_incomplete(monkeypatch, tmp_path): + y = tmp_path / "sponsors.yaml" + y.write_text( + 'sponsors:\n' + ' - {name: "Good", url: "https://good.example", tier: "gold"}\n' + ' - {name: "AlsoGood", url: "http://ok.example"}\n' + ' - {name: "JS", url: "javascript:alert(1)"}\n' + ' - {name: "File", url: "file:///etc/passwd"}\n' + ' - {url: "https://noname.example"}\n' + ' - {name: "NoUrl"}\n' + ' - {name: "BadTier", url: "https://bt.example", tier: "platinum"}\n' + ) + monkeypatch.setattr(sp, "_YAML", y) + monkeypatch.setattr(sp, "_sponsors_url", lambda: "") + out = sp.load() + assert [s.url for s in out] == ["https://good.example", "http://ok.example", "https://bt.example"] + assert out[0].tier == "gold" + assert out[2].tier == "bronze" # unknown tier -> default + + +def test_featured_deterministic_and_from_list(monkeypatch): + _reset() + pool = [Sponsor("A", "https://a.example"), Sponsor("B", "https://b.example")] + monkeypatch.setattr(sp, "load", lambda: pool) + r1 = sp.featured(1, seed=0) + _reset() + r2 = sp.featured(1, seed=0) + assert len(r1) == 1 and r1[0] in pool + assert r1[0].url == r2[0].url # deterministic for a fixed seed + + +def test_featured_empty_list(monkeypatch): + _reset() + monkeypatch.setattr(sp, "load", lambda: []) + assert sp.featured(1, seed=0) == [] + + +def test_tier_weighting_favors_gold(monkeypatch): + pool = [Sponsor("Gold", "https://g.example", "", "gold")] + [ + Sponsor(f"B{i}", f"https://b{i}.example", "", "bronze") for i in range(9) + ] + monkeypatch.setattr(sp, "load", lambda: pool) + n = 300 + gold = 0 + for seed in range(n): + _reset() + picked = sp.featured(1, seed=seed) + if picked and picked[0].name == "Gold": + gold += 1 + # P(gold) = 3/(3+9) = 0.25 weighted vs 0.10 uniform -> weighting is clearly applied. + assert gold > n * 0.15 + + +def test_no_network_by_default(monkeypatch): + _reset() + monkeypatch.setattr(sp, "SPONSORS_URL", "") # fixed-in-code feed is blank by default + monkeypatch.delenv("HACKINGTOOL_NO_SPONSOR_FETCH", raising=False) + + def boom(*a, **k): + raise AssertionError("network must not be touched when SPONSORS_URL is blank") + + monkeypatch.setattr(sp.urllib.request, "urlopen", boom) + assert isinstance(sp.load(), list) # no exception, no network + + +def test_sponsor_cta(): + assert sp.sponsor_cta() == "https://github.com/sponsors/Z4nzu" diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py new file mode 100644 index 0000000..8db9c18 --- /dev/null +++ b/tests/test_tui_app.py @@ -0,0 +1,191 @@ +import asyncio + +from textual.widgets import Input, OptionList + +from hackingtool.tui.app import HackingToolApp +from hackingtool.tui.screens.main import MainScreen +from hackingtool.tui.screens.category import CategoryScreen +from hackingtool.tui.widgets.command_palette import CommandPalette + + +async def _until_screen(pilot, kind): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, kind): + return + raise AssertionError(f"{kind.__name__} never appeared") + + +def _prompt(app) -> Input: + return app.screen.query_one("#ht-prompt", Input) + + +async def _type(pilot, text: str) -> None: + # drive the real on_input_changed path + _prompt(pilot.app).value = text + await pilot.pause() + + +def test_boots_to_main_screen(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + assert isinstance(pilot.app.screen, MainScreen) + + asyncio.run(run()) + + +def test_slash_opens_palette(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + assert len(pilot.app.screen.query(CommandPalette)) == 0 + await _type(pilot, "/") + assert len(pilot.app.screen.query(CommandPalette)) == 1 + + asyncio.run(run()) + + +def test_slash_filters_palette(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + await _type(pilot, "/conf") + pal = pilot.app.screen.query_one(CommandPalette) + ids = [pal.get_option_at_index(i).id for i in range(pal.option_count)] + assert ids == ["/config"] + + asyncio.run(run()) + + +def test_clearing_slash_removes_palette(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + await _type(pilot, "/") + assert len(pilot.app.screen.query(CommandPalette)) == 1 + await _type(pilot, "") + assert len(pilot.app.screen.query(CommandPalette)) == 0 + + asyncio.run(run()) + + +def test_palette_quit_exits(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + await _type(pilot, "/quit") + await pilot.press("enter") + await pilot.pause() + assert pilot.app._exit is True + + asyncio.run(run()) + + +def test_palette_config_opens_config_screen(): + # /config now pushes a native in-TUI settings screen (no prompt_toolkit / suspend). + from hackingtool.tui.screens.config import ConfigScreen + + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + await _type(pilot, "/conf") + await pilot.press("enter") + await _until_screen(pilot, ConfigScreen) + assert isinstance(pilot.app.screen, ConfigScreen) + + asyncio.run(run()) + + +def test_palette_update_opens_update_modal_and_copies(): + from hackingtool.tui.widgets.update_modal import UpdateModal + + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + copied = {} + pilot.app.copy_to_clipboard = lambda s: copied.setdefault("cmd", s) + await _type(pilot, "/update") + await pilot.press("enter") + await _until_screen(pilot, UpdateModal) + await pilot.press("enter") # copy detected command + close + await _until_screen(pilot, MainScreen) + assert copied.get("cmd") # a command was copied + assert isinstance(pilot.app.screen, MainScreen) + + asyncio.run(run()) + + +def test_escape_with_palette_closes_it_and_clears_prompt(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + await _type(pilot, "/con") + assert len(pilot.app.screen.query(CommandPalette)) == 1 + await pilot.press("escape") + await pilot.pause() + assert len(pilot.app.screen.query(CommandPalette)) == 0 + assert _prompt(pilot.app).value == "" + + asyncio.run(run()) + + +def test_escape_without_palette_pops_category_screen(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + # enter on the highlighted category pushes a CategoryScreen + await pilot.press("enter") + await _until_screen(pilot, CategoryScreen) + assert len(pilot.app.screen.query(CommandPalette)) == 0 + await pilot.press("escape") + await _until_screen(pilot, MainScreen) + assert isinstance(pilot.app.screen, MainScreen) + + asyncio.run(run()) + + +def test_palette_floats_above_prompt_without_scrolling(): + async def run(): + async with HackingToolApp().run_test(size=(100, 30)) as pilot: + await _until_screen(pilot, MainScreen) + await _type(pilot, "/") + pal = pilot.app.screen.query_one(CommandPalette) + prompt = _prompt(pilot.app) + # prompt still docked at the very bottom (screen didn't grow/scroll) + assert prompt.region.bottom == 30 + # palette sits ABOVE the prompt + assert pal.region.bottom <= prompt.region.y + assert pal.region.y < prompt.region.y + + asyncio.run(run()) + + +def test_down_from_prompt_moves_list_highlight(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + assert isinstance(pilot.app.focused, Input) # prompt is focused on mount + ol = pilot.app.screen.query_one("#main-cats", OptionList) + assert ol.highlighted == 0 + await pilot.press("down") + await pilot.pause() + # focus stays on the prompt; the list highlight advances + assert isinstance(pilot.app.focused, Input) + assert ol.highlighted == 1 + + asyncio.run(run()) + + +def test_down_from_prompt_moves_palette_highlight(): + async def run(): + async with HackingToolApp().run_test() as pilot: + await _until_screen(pilot, MainScreen) + await _type(pilot, "/") + pal = pilot.app.screen.query_one(CommandPalette) + assert pal.highlighted == 0 + await pilot.press("down") + await pilot.pause() + assert pal.highlighted == 1 # down steers the palette, not the list + + asyncio.run(run()) diff --git a/tests/test_tui_banner.py b/tests/test_tui_banner.py new file mode 100644 index 0000000..b26a313 --- /dev/null +++ b/tests/test_tui_banner.py @@ -0,0 +1,134 @@ +import asyncio +import io + +from rich.console import Console + +from hackingtool import sponsors +from hackingtool.constants import REPO_WEB_URL, VERSION_DISPLAY +from hackingtool.tui import data +from hackingtool.tui.screens.main import MainScreen +from hackingtool.tui.screens.category import CategoryScreen +from hackingtool.tui.widgets.banner import SponsorBanner +from hackingtool.tui.widgets.header import Header +from textual.app import App + + +def _ansi(text, width=120) -> str: + buf = io.StringIO() + Console(file=buf, force_terminal=True, width=width).print(text) + return buf.getvalue() + + +def test_logo_is_big_oneword_block_art(): + logo = SponsorBanner(42)._logo_text().plain + lines = [l for l in logo.split("\n") if l.strip()] + assert len(lines) == 6 # big block font, 6 rows + assert "β–ˆ" in logo # block art, not plain letters + assert "TOOL" not in logo # not the stacked HACKING/TOOL split β€” one word + + +def test_tagline_product_line(): + text = SponsorBanner(42)._tagline_text().plain + assert "pentest" in text + assert VERSION_DISPLAY in text + assert "42 tools" in text + assert REPO_WEB_URL in text + + +def test_empty_sponsor_cta(): + # committed sponsors.yaml ships empty -> CTA empty-state line + text = SponsorBanner(42)._sponsor_text().plain + assert "Sponsor hackingtool" in text + assert sponsors.sponsor_cta() in text + assert "No sponsors yet" not in text + assert "your name appears here" not in text + + +def test_featured_sponsor_shown(monkeypatch): + s = sponsors.Sponsor("Acme Security", "https://acme.example", "Cloud pentest", "gold") + monkeypatch.setattr(sponsors, "featured", lambda n=1: [s]) + text = SponsorBanner(10)._sponsor_text().plain + assert "Acme Security" in text + assert "https://acme.example" in text + assert "Cloud pentest" in text + # CTA still present alongside the active sponsor + assert sponsors.sponsor_cta() in text + + +def test_osc8_links_rendered(monkeypatch): + s = sponsors.Sponsor("Acme Security", "https://acme.example", "", "gold") + monkeypatch.setattr(sponsors, "featured", lambda n=1: [s]) + b = SponsorBanner(10) + out = _ansi(b._tagline_text()) + _ansi(b._sponsor_text()) + assert "\x1b]8;" in out + assert "github.com/Z4nzu/hackingtool" in out + assert "acme.example" in out + + +class _Host(App): + def __init__(self, screen): + self._screen = screen + super().__init__() + + def on_mount(self) -> None: + self.push_screen(self._screen) + + +async def _until(pilot, kind): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, kind): + return + raise AssertionError(f"{kind.__name__} never appeared") + + +def test_main_screen_uses_banner(): + async def run(): + async with _Host(MainScreen()).run_test() as pilot: + await _until(pilot, MainScreen) + banner = pilot.app.screen.query_one(SponsorBanner) + assert banner.query_one("#ht-logo") + assert banner.query_one("#ht-tagline") + assert banner.query_one("#ht-sponsor") + asyncio.run(run()) + + +def test_update_notice_shown_when_newer(monkeypatch): + from hackingtool.tui.widgets import banner as b + from textual.widgets import Static + monkeypatch.setattr(b.update_check, "latest_if_newer", lambda *a, **k: "9.9.9") + + async def run(): + async with _Host(MainScreen()).run_test() as pilot: + await _until(pilot, MainScreen) + upd = pilot.app.screen.query_one("#ht-update", Static) + for _ in range(40): # wait for the background thread worker + await pilot.pause() + if str(upd.render()).strip(): + break + assert "9.9.9" in str(upd.render()) + asyncio.run(run()) + + +def test_no_update_notice_when_current(monkeypatch): + from hackingtool.tui.widgets import banner as b + from textual.widgets import Static + monkeypatch.setattr(b.update_check, "latest_if_newer", lambda *a, **k: None) + + async def run(): + async with _Host(MainScreen()).run_test() as pilot: + await _until(pilot, MainScreen) + for _ in range(10): + await pilot.pause() + assert str(pilot.app.screen.query_one("#ht-update", Static).render()).strip() == "" + asyncio.run(run()) + + +def test_category_screen_keeps_header(): + idx = next(c.index for c in data.categories() if c.count > 0) + + async def run(): + async with _Host(CategoryScreen(idx)).run_test() as pilot: + await _until(pilot, CategoryScreen) + assert pilot.app.screen.query_one(Header) + asyncio.run(run()) diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py new file mode 100644 index 0000000..8398810 --- /dev/null +++ b/tests/test_tui_data.py @@ -0,0 +1,29 @@ +from hackingtool.tui import data + + +def test_categories_nonempty_and_shaped(): + cats = data.categories() + assert len(cats) > 1 + c = cats[0] + assert c.title and isinstance(c.icon, str) and c.count >= 0 + + +def test_tools_in_first_category(): + cats = data.categories() + tools = data.tools_in(cats[0].index) + assert isinstance(tools, list) + if tools: + t = tools[0] + assert t.title + assert isinstance(t.installed, bool) + assert t.obj is not None # the live engine instance for actions + + +def test_tool_at_round_trips(): + cats = data.categories() + for ci, c in enumerate(cats): + tools = data.tools_in(c.index) + if tools: + got = data.tool_at(c.index, tools[0].index) + assert got.title == tools[0].title + break diff --git a/tests/test_tui_glyphs.py b/tests/test_tui_glyphs.py new file mode 100644 index 0000000..7ff9754 --- /dev/null +++ b/tests/test_tui_glyphs.py @@ -0,0 +1,10 @@ +from hackingtool.tui import glyphs + + +def test_glyphs_are_single_source(): + assert glyphs.PROMPT == "❯" + assert glyphs.RUN == "⏺" + assert glyphs.SUB == "⎿" + assert glyphs.STATUS == "β—†" + assert glyphs.INSTALLED == "βœ”" + assert glyphs.NOT_INSTALLED == "β—‹" diff --git a/tests/test_tui_header.py b/tests/test_tui_header.py new file mode 100644 index 0000000..9276776 --- /dev/null +++ b/tests/test_tui_header.py @@ -0,0 +1,22 @@ +import asyncio + +from textual.app import App, ComposeResult + +from hackingtool.tui.widgets.header import Header + + +class _App(App): + def compose(self) -> ComposeResult: + yield Header("hackingtool v3.0.0", "ready", 217) + + +def test_header_renders_title_status_count(): + async def run(): + async with _App().run_test() as pilot: + h = pilot.app.query_one(Header) + text = h.render().plain + assert "hackingtool v3.0.0" in text + assert "ready" in text + assert "217" in text + + asyncio.run(run()) diff --git a/tests/test_tui_header_visible.py b/tests/test_tui_header_visible.py new file mode 100644 index 0000000..5e213d9 --- /dev/null +++ b/tests/test_tui_header_visible.py @@ -0,0 +1,72 @@ +import asyncio + +from textual.app import App + +from hackingtool.tui import data +from hackingtool.tui.data import ToolRow +from hackingtool.tui.screens.main import MainScreen +from hackingtool.tui.screens.category import CategoryScreen +from hackingtool.tui.screens.tool import ToolScreen +from hackingtool.tui.widgets.header import Header +from hackingtool.tui.widgets.banner import SponsorBanner + + +class _Fake: + def run(self): + pass + + +def _cat_with_tools() -> int: + return next(c.index for c in data.categories() if c.count > 0) + + +def _tool_row() -> ToolRow: + return ToolRow(index=0, title="Sublist3r", description="Enumerate subdomains", + url="", installed=True, obj=_Fake()) + + +class _Host(App): + def __init__(self, screen): + self._screen = screen + super().__init__() + + def on_mount(self) -> None: + self.push_screen(self._screen) + + +async def _until_screen(pilot, kind): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, kind): + return + raise AssertionError(f"{kind.__name__} never appeared") + + +def _assert_header(screen_factory, kind): + async def run(): + async with _Host(screen_factory()).run_test() as pilot: + await _until_screen(pilot, kind) + header = pilot.app.screen.query_one(Header) + assert "hackingtool" in header.render().plain + + asyncio.run(run()) + + +def test_main_screen_header_visible(): + # MainScreen swaps the thin Header for the richer SponsorBanner. + async def run(): + async with _Host(MainScreen()).run_test() as pilot: + await _until_screen(pilot, MainScreen) + banner = pilot.app.screen.query_one(SponsorBanner) + assert "pentest" in banner.query_one("#ht-tagline").content.plain + + asyncio.run(run()) + + +def test_category_screen_header_visible(): + idx = _cat_with_tools() + _assert_header(lambda: CategoryScreen(idx), CategoryScreen) + + +def test_tool_screen_header_visible(): + _assert_header(lambda: ToolScreen(_tool_row()), ToolScreen) diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py new file mode 100644 index 0000000..52c624c --- /dev/null +++ b/tests/test_tui_launch.py @@ -0,0 +1,30 @@ +import sys +from unittest import mock +import hackingtool.cli as cli + + +# NOTE: cli imports get_tools_dir via `from hackingtool.config import get_tools_dir`, +# so the live name is hackingtool.cli.get_tools_dir (patching hackingtool.config.* +# would not intercept it). ensure_user_files is called as config.ensure_user_files, +# so it is patched on the config module. +def test_default_launches_tui(monkeypatch): + monkeypatch.setattr(sys, "argv", ["hackingtool"]) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + with mock.patch("hackingtool.tui.app.run") as tui, \ + mock.patch("hackingtool.config.ensure_user_files"), \ + mock.patch("hackingtool.cli.get_tools_dir"): + cli.main() + assert tui.called + + +def test_classic_flag_skips_tui(monkeypatch): + monkeypatch.setattr(sys, "argv", ["hackingtool", "--classic"]) + with mock.patch("hackingtool.tui.app.run") as tui, \ + mock.patch("hackingtool.cli.interact_menu") as classic, \ + mock.patch("hackingtool.repl._use_repl", return_value=False), \ + mock.patch("hackingtool.config.ensure_user_files"), \ + mock.patch("hackingtool.cli.get_tools_dir"): + cli.main() + assert not tui.called + assert classic.called diff --git a/tests/test_tui_navigation.py b/tests/test_tui_navigation.py new file mode 100644 index 0000000..b4a5240 --- /dev/null +++ b/tests/test_tui_navigation.py @@ -0,0 +1,106 @@ +"""Regression tests for the TUI navigation/UX fixes (focus, left-back, hint line, stub feedback).""" +import asyncio + +from textual.widgets import Input, OptionList, Static + +from hackingtool.tui import data +from hackingtool.tui.app import HackingToolApp +from hackingtool.tui.screens.main import MainScreen +from hackingtool.tui.screens.category import CategoryScreen + + +async def _until(pilot, kind): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, kind): + return + raise AssertionError(f"{kind.__name__} never appeared") + + +def test_clicking_list_keeps_typing_on_prompt(): + # issue 4: list is not focusable, so a click can't steal focus from the prompt + async def run(): + async with HackingToolApp().run_test(size=(100, 30)) as pilot: + await _until(pilot, MainScreen) + ol = pilot.app.screen.query_one("#main-cats", OptionList) + assert ol.can_focus is False + await pilot.click("#main-cats") + await pilot.pause() + assert isinstance(pilot.app.focused, Input) # focus did NOT move to the list + await pilot.press("w") + await pilot.pause() + assert pilot.app.screen.query_one("#ht-prompt", Input).value == "w" + asyncio.run(run()) + + +def test_left_arrow_goes_back_when_prompt_empty(): + # issue 2/3: ← pops the screen when the prompt is empty + async def run(): + async with HackingToolApp().run_test(size=(100, 30)) as pilot: + await _until(pilot, MainScreen) + await pilot.press("enter") # open a category + await _until(pilot, CategoryScreen) + await pilot.press("left") + await _until(pilot, MainScreen) + assert isinstance(pilot.app.screen, MainScreen) + asyncio.run(run()) + + +def test_left_arrow_edits_text_when_prompt_has_value(): + # ← must still move the cursor (not navigate) while typing + async def run(): + async with HackingToolApp().run_test(size=(100, 30)) as pilot: + await _until(pilot, MainScreen) + await pilot.press("enter") + await _until(pilot, CategoryScreen) + inp = pilot.app.screen.query_one("#ht-prompt", Input) + inp.value = "ab" + inp.cursor_position = 2 + await pilot.pause() + await pilot.press("left") + await pilot.pause() + assert isinstance(pilot.app.screen, CategoryScreen) # did not go back + assert inp.cursor_position == 1 # cursor moved instead + asyncio.run(run()) + + +def test_left_arrow_does_not_pop_main_screen(): + async def run(): + async with HackingToolApp().run_test(size=(100, 30)) as pilot: + await _until(pilot, MainScreen) + await pilot.press("left") + await pilot.pause() + assert isinstance(pilot.app.screen, MainScreen) # nothing to pop + asyncio.run(run()) + + +def test_hint_line_visible_and_context_aware(): + # issue 1/3: a real hint row (not a truncated border subtitle); shows back only when poppable + async def run(): + async with HackingToolApp().run_test(size=(100, 30)) as pilot: + await _until(pilot, MainScreen) + main_hint = pilot.app.screen.query_one("#ht-hint", Static) + assert "/ commands" in main_hint.content + assert "back" not in main_hint.content # root: no back + await pilot.press("enter") + await _until(pilot, CategoryScreen) + cat_hint = pilot.app.screen.query_one("#ht-hint", Static) + assert "← esc back" in cat_hint.content # category: back shown + asyncio.run(run()) + + +def test_stub_command_notifies_instead_of_silent(): + # issue 5: /report is a Phase-2 stub -> must surface feedback, not do nothing + async def run(): + async with HackingToolApp().run_test(size=(100, 30)) as pilot: + await _until(pilot, MainScreen) + seen = {} + pilot.app.notify = lambda *a, **k: seen.setdefault("msg", (a, k)) + inp = pilot.app.screen.query_one("#ht-prompt", Input) + inp.value = "/report" + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert "msg" in seen + assert "/report" in seen["msg"][0][0] + asyncio.run(run()) diff --git a/tests/test_tui_permission.py b/tests/test_tui_permission.py new file mode 100644 index 0000000..49b2c0e --- /dev/null +++ b/tests/test_tui_permission.py @@ -0,0 +1,45 @@ +import asyncio + +from textual import work +from textual.app import App +from textual.widgets import RadioSet + +from hackingtool.tui.widgets.permission import PermissionModal + + +class _App(App): + result = None + + @work + async def on_mount(self) -> None: + self.result = await self.push_screen_wait( + PermissionModal("Run nmap against 3 targets?") + ) + self.exit() + + +async def _drive(*keys: str): + async with _App().run_test() as pilot: + for _ in range(20): + await pilot.pause() + if isinstance(pilot.app.screen, PermissionModal): + break + assert pilot.app.screen.query(RadioSet) # modal is up and focusable + for key in keys: + await pilot.press(key) + await pilot.pause() + return pilot.app.result + + +def test_yes_is_highlighted_first_and_enter_returns_yes(): + # on-mount highlights "Yes" (index 0); enter presses it + assert asyncio.run(_drive("enter")) == "yes" + + +def test_down_then_enter_returns_no(): + # down -> highlight "No" (index 1), enter -> press it + assert asyncio.run(_drive("down", "enter")) == "no" + + +def test_escape_returns_no(): + assert asyncio.run(_drive("escape")) == "no" diff --git a/tests/test_tui_screens_category.py b/tests/test_tui_screens_category.py new file mode 100644 index 0000000..1e22b88 --- /dev/null +++ b/tests/test_tui_screens_category.py @@ -0,0 +1,75 @@ +import asyncio + +from textual.app import App +from textual.widgets import OptionList, Input + +from hackingtool.tui import data +from hackingtool.tui.screens.category import CategoryScreen +from hackingtool.tui.screens.tool import ToolScreen + + +def _cat_with_tools() -> int: + return next(c.index for c in data.categories() if c.count > 0) + + +class _App(App): + def __init__(self, idx): + self._idx = idx + super().__init__() + + async def on_mount(self) -> None: + await self.push_screen(CategoryScreen(self._idx)) + + +async def _until_screen(pilot, kind): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, kind): + return + raise AssertionError(f"{kind.__name__} never appeared") + + +def test_lists_tools(): + idx = _cat_with_tools() + + async def run(): + async with _App(idx).run_test() as pilot: + await _until_screen(pilot, CategoryScreen) + ol = pilot.app.screen.query_one("#cat-tools", OptionList) + assert ol.option_count > 0 + + asyncio.run(run()) + + +def test_filter_narrows_list(): + idx = _cat_with_tools() + rows = data.tools_in(idx) + needle = rows[0].title[:3] + expected = sum(1 for r in rows if needle.lower() in r.title.lower()) + + async def run(): + async with _App(idx).run_test() as pilot: + await _until_screen(pilot, CategoryScreen) + ol = pilot.app.screen.query_one("#cat-tools", OptionList) + original = ol.option_count + inp = pilot.app.screen.query_one("#ht-prompt", Input) + inp.value = needle + await pilot.pause() + assert ol.option_count == expected + assert 0 < ol.option_count <= original + + asyncio.run(run()) + + +def test_enter_on_prompt_pushes_tool_screen(): + idx = _cat_with_tools() + + async def run(): + async with _App(idx).run_test() as pilot: + await _until_screen(pilot, CategoryScreen) + assert isinstance(pilot.app.focused, Input) # prompt focused on mount + await pilot.press("enter") + await _until_screen(pilot, ToolScreen) + assert isinstance(pilot.app.screen, ToolScreen) + + asyncio.run(run()) diff --git a/tests/test_tui_screens_config.py b/tests/test_tui_screens_config.py new file mode 100644 index 0000000..9d53e88 --- /dev/null +++ b/tests/test_tui_screens_config.py @@ -0,0 +1,83 @@ +"""ConfigScreen: native settings editor β€” filter, cycle choices (persisted), read-only guard.""" +import asyncio + +from textual.app import App +from textual.widgets import Input, OptionList + +from hackingtool import config +from hackingtool.tui.screens.config import ConfigScreen + + +class _App(App): + async def on_mount(self) -> None: + await self.push_screen(ConfigScreen()) + + +async def _until(pilot): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, ConfigScreen): + return + raise AssertionError("ConfigScreen never appeared") + + +def test_lists_settings(): + async def run(): + async with _App().run_test() as pilot: + await _until(pilot) + ol = pilot.app.screen.query_one("#config-rows", OptionList) + ids = [ol.get_option_at_index(i).id for i in range(ol.option_count)] + assert "theme" in ids + assert "ai_key" in ids # synthetic secret row present + asyncio.run(run()) + + +def test_filter_narrows_settings(): + async def run(): + async with _App().run_test() as pilot: + await _until(pilot) + ol = pilot.app.screen.query_one("#config-rows", OptionList) + inp = pilot.app.screen.query_one("#ht-prompt", Input) + inp.value = "theme" + await pilot.pause() + assert ol.option_count == 1 + assert ol.get_option_at_index(0).id == "theme" + asyncio.run(run()) + + +def test_enter_cycles_choice_and_persists(monkeypatch): + saved = {} + + def _fake_set(k, v): + saved[k] = v + return (True, "ok") + + monkeypatch.setattr(config, "set_value", _fake_set) + + async def run(): + async with _App().run_test() as pilot: + await _until(pilot) + inp = pilot.app.screen.query_one("#ht-prompt", Input) + inp.value = "show archived" # a bool row -> cycles true/false + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert "show_archived" in saved # set_value was called with the new value + asyncio.run(run()) + + +def test_readonly_row_notifies_instead_of_editing(): + async def run(): + async with _App().run_test() as pilot: + await _until(pilot) + scr = pilot.app.screen + seen = {} + pilot.app.notify = lambda *a, **k: seen.setdefault("msg", (a, k)) + inp = scr.query_one("#ht-prompt", Input) + inp.value = "version" # read-only + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert "msg" in seen + assert "read-only" in seen["msg"][0][0].lower() + asyncio.run(run()) diff --git a/tests/test_tui_screens_main.py b/tests/test_tui_screens_main.py new file mode 100644 index 0000000..000b40e --- /dev/null +++ b/tests/test_tui_screens_main.py @@ -0,0 +1,112 @@ +import asyncio + +from textual.app import App +from textual.widgets import OptionList, Input + +from hackingtool.tui import data +from hackingtool.tui.screens.main import MainScreen +from hackingtool.tui.screens.category import CategoryScreen +from hackingtool.tui.screens.tool import ToolScreen + + +class _App(App): + async def on_mount(self) -> None: + await self.push_screen(MainScreen()) + + +async def _until_screen(pilot, kind): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, kind): + return + raise AssertionError(f"{kind.__name__} never appeared") + + +def test_lists_all_categories(): + async def run(): + async with _App().run_test() as pilot: + await _until_screen(pilot, MainScreen) + ol = pilot.app.screen.query_one("#main-cats", OptionList) + assert ol.option_count == len(data.categories()) + + asyncio.run(run()) + + +def test_typing_searches_all_tools_globally(): + # Main-screen typing is a global tool search (reaches tools inside submenus), + # not a category filter. Rows become tool hits (id "t:cat:tool"). + needle = "sql" + expected = len(data.search_tools(needle)) + + async def run(): + async with _App().run_test() as pilot: + await _until_screen(pilot, MainScreen) + ol = pilot.app.screen.query_one("#main-cats", OptionList) + inp = pilot.app.screen.query_one("#ht-prompt", Input) + inp.value = needle + await pilot.pause() + assert expected > 0 + assert ol.option_count == expected + assert ol.get_option_at_index(0).id.startswith("t:") + # clearing returns to the category list + inp.value = "" + await pilot.pause() + assert ol.option_count == len(data.categories()) + + asyncio.run(run()) + + +def test_enter_on_prompt_pushes_category_screen(): + # Prompt is focused and row 0 highlighted on mount; Enter opens it. + async def run(): + async with _App().run_test() as pilot: + await _until_screen(pilot, MainScreen) + assert isinstance(pilot.app.focused, Input) + await pilot.press("enter") + await _until_screen(pilot, CategoryScreen) + assert isinstance(pilot.app.screen, CategoryScreen) + + asyncio.run(run()) + + +def test_enter_on_global_search_hit_opens_tool(): + async def run(): + async with _App().run_test() as pilot: + await _until_screen(pilot, MainScreen) + inp = pilot.app.screen.query_one("#ht-prompt", Input) + inp.value = "sql" + await pilot.pause() + await pilot.press("enter") + await _until_screen(pilot, ToolScreen) + assert isinstance(pilot.app.screen, ToolScreen) + + asyncio.run(run()) + + +def test_prompt_docked_bottom_list_fills_middle(): + # Header pinned top, list in the middle, prompt pinned bottom: the screen + # tiles instead of scrolling as a page. Verified at a short height. + async def run(): + async with _App().run_test(size=(80, 20)) as pilot: + await _until_screen(pilot, MainScreen) + scr = pilot.app.screen + prompt = scr.query_one("#ht-prompt", Input) + ol = scr.query_one("#main-cats", OptionList) + assert not scr.query("#main-filter") # no top filter box + assert prompt.region.y > ol.region.y # prompt below the list + assert prompt.region.y + prompt.region.height == scr.size.height + + asyncio.run(run()) + + +def test_down_on_prompt_moves_list_highlight(): + async def run(): + async with _App().run_test() as pilot: + await _until_screen(pilot, MainScreen) + ol = pilot.app.screen.query_one("#main-cats", OptionList) + assert ol.highlighted == 0 + await pilot.press("down") + await pilot.pause() + assert ol.highlighted == 1 + + asyncio.run(run()) diff --git a/tests/test_tui_screens_tool.py b/tests/test_tui_screens_tool.py new file mode 100644 index 0000000..07f2b8d --- /dev/null +++ b/tests/test_tui_screens_tool.py @@ -0,0 +1,93 @@ +import asyncio +import contextlib + +from textual.app import App +from textual.widgets import OptionList + +from hackingtool.tui.data import ToolRow +from hackingtool.tui.screens.tool import ToolScreen +from hackingtool.tui.widgets.permission import PermissionModal + + +class _Fake: + ran = False + + def run(self): + self.ran = True + + +def _row(): + return ToolRow(index=0, title="Sublist3r", description="Enumerate subdomains", + url="", installed=True, obj=_Fake()) + + +class _App(App): + def __init__(self, row): + self._row = row + super().__init__() + + def on_mount(self) -> None: + self.push_screen(ToolScreen(self._row)) + + +async def _until_screen(pilot, kind): + for _ in range(40): + await pilot.pause() + if isinstance(pilot.app.screen, kind): + return + raise AssertionError(f"{kind.__name__} never appeared") + + +def test_structure_option_ids(): + async def run(): + async with _App(_row()).run_test() as pilot: + await _until_screen(pilot, ToolScreen) + ol = pilot.app.screen.query_one("#tool-actions", OptionList) + ids = [ol.get_option_at_index(i).id for i in range(ol.option_count)] + assert set(ids) == {"run", "install", "open", "uninstall", "back"} + + asyncio.run(run()) + + +def test_permission_gate_runs_fn(): + # HeadlessDriver.can_suspend is False, so real App.suspend() raises + # SuspendNotSupported under run_test(); override it with a no-op so fn() still + # fires. Production code keeps the real suspend() to give tools a TTY. + # PermissionModal highlights "Yes" (index 0) on mount with nothing pre-pressed, + # so a single "enter" presses it β†’ "yes" and proceeds past the gate. + row = _row() + + async def run(): + async with _App(row).run_test() as pilot: + await _until_screen(pilot, ToolScreen) + pilot.app.suspend = lambda: contextlib.nullcontext() + ol = pilot.app.screen.query_one("#tool-actions", OptionList) + ol.focus() + ol.highlighted = ol.get_option_index("run") + await pilot.press("enter") + await _until_screen(pilot, PermissionModal) + await pilot.press("enter") + for _ in range(40): + await pilot.pause() + if row.obj.ran: + break + assert row.obj.ran + + asyncio.run(run()) + + +def test_back_pops_screen(): + async def run(): + async with _App(_row()).run_test() as pilot: + await _until_screen(pilot, ToolScreen) + ol = pilot.app.screen.query_one("#tool-actions", OptionList) + ol.focus() + ol.highlighted = ol.get_option_index("back") + await pilot.press("enter") + for _ in range(40): + await pilot.pause() + if not isinstance(pilot.app.screen, ToolScreen): + break + assert not isinstance(pilot.app.screen, ToolScreen) + + asyncio.run(run()) diff --git a/tests/test_tui_slash_menu.py b/tests/test_tui_slash_menu.py new file mode 100644 index 0000000..22d7645 --- /dev/null +++ b/tests/test_tui_slash_menu.py @@ -0,0 +1,67 @@ +import asyncio + +from textual.app import App, ComposeResult + +from hackingtool.tui.widgets.command_palette import CommandPalette, COMMANDS + + +class _App(App): + picked = None + + def compose(self) -> ComposeResult: + yield CommandPalette() + + def on_command_palette_selected(self, e: CommandPalette.Selected) -> None: + self.picked = e.command + + +def _ids(pal: CommandPalette) -> list[str]: + return [pal.get_option_at_index(i).id for i in range(pal.option_count)] + + +def test_empty_needle_shows_all_commands(): + pal = CommandPalette() + opts = pal._opts("") + assert len(opts) == len(COMMANDS) == 6 + assert "/config" in [o.id for o in opts] + assert "/update" in [o.id for o in opts] + assert "/search" not in [o.id for o in opts] # type-to-filter replaces it + + +def test_opts_narrows_by_name(): + pal = CommandPalette() + assert [o.id for o in pal._opts("conf")] == ["/config"] + + +def test_opts_narrows_by_help_text(): + pal = CommandPalette() + assert [o.id for o in pal._opts("export")] == ["/report"] # /report's help + + +def test_filter_updates_list_and_highlights_first(): + async def run(): + app = _App() + async with app.run_test() as pilot: + await pilot.pause() + pal = app.query_one(CommandPalette) + assert pal.option_count == len(COMMANDS) + pal.filter("conf") + await pilot.pause() + assert _ids(pal) == ["/config"] + assert pal.highlighted == 0 + + asyncio.run(run()) + + +def test_selecting_emits_selected(): + async def run(): + app = _App() + async with app.run_test() as pilot: + await pilot.pause() + pal = app.query_one(CommandPalette) + pal.highlighted = pal.get_option_index("/config") + pal.action_select() # palette is non-focusable; real UI drives it from the prompt/mouse + await pilot.pause() + assert app.picked == "/config" + + asyncio.run(run()) diff --git a/tests/test_tui_working.py b/tests/test_tui_working.py new file mode 100644 index 0000000..d6f46be --- /dev/null +++ b/tests/test_tui_working.py @@ -0,0 +1,17 @@ +from hackingtool.tui import glyphs, working + + +def test_line_starts_with_run_glyph_and_word(): + out = working.line() + assert out.startswith(glyphs.RUN) + assert any(w in out for w in working.WORDS) + assert "…" in out + + +def test_line_appends_suffix(): + assert "running Nmap" in working.line("running Nmap") + + +def test_words_are_nonempty_strings(): + assert working.WORDS + assert all(isinstance(w, str) and w for w in working.WORDS) diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000..dfac0a1 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,41 @@ +"""update_check: version comparison + fail-safe behaviour (never raises, honours opt-out).""" +import hackingtool.update_check as uc + + +def _reset(): + uc._memo_done = False + uc._memo_val = None + + +def test_parse_handles_v_prefix_and_prerelease(): + assert uc._parse("v3.0.1") == (3, 0, 1) + assert uc._parse("3.0") == (3, 0, 0) + assert uc._parse("v3.1.0-rc1") == (3, 1, 0) + assert uc._parse("v3.0.1") > uc._parse("3.0.0") + + +def test_newer_release_reported(monkeypatch): + _reset() + monkeypatch.setattr(uc, "_cached_latest", lambda timeout: "v9.9.9") + assert uc.latest_if_newer() == "9.9.9" + + +def test_same_or_older_returns_none(monkeypatch): + _reset() + monkeypatch.setattr(uc, "_cached_latest", lambda timeout: f"v{uc.VERSION}") + assert uc.latest_if_newer() is None + + +def test_network_error_is_swallowed(monkeypatch): + _reset() + def boom(timeout): + raise OSError("offline") + monkeypatch.setattr(uc, "_cached_latest", boom) + assert uc.latest_if_newer() is None + + +def test_env_opt_out(monkeypatch): + _reset() + monkeypatch.setenv("HACKINGTOOL_NO_UPDATE_CHECK", "1") + monkeypatch.setattr(uc, "_cached_latest", lambda timeout: "v9.9.9") + assert uc.latest_if_newer() is None