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**.
-

+
-
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