feat: interactive TUI, self-update & native settings; apply community bug fixes

Interactive terminal UI (Textual, default on a TTY)
- Category browser with live tool counts; type to search EVERY tool across all
  categories; up/down move, Enter open, left/Esc back; `/` opens a command palette.
- Tool screen gates Run/Install/Uninstall behind a confirm, dropping to a real
  TTY so a tool can't crash the UI; Open-repo is http(s)-guarded.
- The engine is unchanged; `--classic` keeps the full prompt_toolkit REPL and the
  headless `--engagement` orchestrator is untouched.

Update awareness + self-update helper
- Cached-daily GitHub Releases check (offline-safe, opt-out via
  HACKINGTOOL_NO_UPDATE_CHECK / update_check config); shows an "update available"
  line on the main screen via a background worker (never blocks first render).
- `/update` detects the install method (pipx/pip/apt/docker/source) and shows the
  exact upgrade command to copy - it never runs it.

Native settings + config hygiene
- `/update`/`/config` run as in-TUI screens instead of suspending to
  prompt_toolkit (which raised "event loop already running" nested under Textual).
- Sponsor feed URL moved from user config to a fixed code constant (SPONSORS_URL);
  update_check is now a proper bool toggle.

Docker / README
- Docker image references aligned to hardikzinzu/hackingtool.
- README refreshed for the interactive UI; first screenshot replaced with the new UI.

Community-reported bug fixes (verified still present in current code)
- Data safety: atomic JSON/YAML writes (#773, #765); malformed config.json no
  longer crashes load/import (#775); UTF-8 for catalog/settings IO (#766);
  findings parsers skip non-object/typed records instead of aborting (#768);
  pipeline decodes invalid bytes and salvages timed-out evidence (#771, #770).
- AI layer: reject null/non-text provider responses (#778); auto-mode connection
  test honors the Ollama fallback (#779); settings stay responsive during the AI
  probe (#780); one findings snapshot per AI report (#772).
- CLI/REPL: `@nmap`/`@sqlmap` resolve canonically instead of fuzzy mis-hits (#767);
  bare `/` no longer crashes the REPL (#761); bad/incomplete args exit non-zero
  instead of opening the menu (#763); search/tag result #99 is reachable (#762);
  background pane creation failures are reported, not shown as "started" (#777);
  config/API-key save failures surface without closing settings (#776).
- Misc: .dockerignore keeps README.md for the build (#781); PortScan prompt markup
  fixed (#698); uninstall command lines use [warning] not [error] (#666, #663);
  untrusted-output fence can't be broken out of + script import fix (#757).
This commit is contained in:
Hardik Zinzu committed 2026-10-15 19:01:16 +05:30
1 parent 3204c47556
commit 4a9c3d0075
71 files changed
+2618 -97

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
.github/
images/
*.md
!README.md # pyproject reads it during `pip install .`; Dockerfile COPYs it
# Python cache
__pycache__/
+19 -12
View File
@@ -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**.
<div align="center">
<img src="images/screenshots/1.png" alt="hackingtool console: banner with live system readout and the / command palette" width="900">
<img src="images/screenshots/1.png" alt="hackingtool interactive TUI: categories with live tool counts, type to search all tools, / opens the command palette" width="900">
<br/>
<sub>The console on launch — live system readout, and <code>/</code> opens the command palette.</sub>
<sub>The interactive console on launch — browse categories, type to search every tool, and <code>/</code> opens the command palette.</sub>
</div>
---
@@ -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
<sub><code>/help</code> — the same card, in the app.</sub>
</div>
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 <key> <value>` 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 <key> <value>` 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 120 KiB

+2 -2
View File
@@ -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" }
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -17,7 +17,7 @@ OUT = SRC / "catalog_owners.py"
_URL = re.compile(r"(?<!api\.)github\.com/([A-Za-z0-9._-]+)/[A-Za-z0-9._-]+")
# Path segments that land right after github.com/ in non-owner URLs (placeholder
# docs, API paths that slipped past the api. filter) — never real owners.
_NOT_OWNERS = {"search", "repos", "orgs", "org"}
_NOT_OWNERS = {"search", "repos", "orgs", "org", "sponsors"}
HEADER = '''"""GitHub owners of tools we already ship — GENERATED, do not edit.
+4
View File
@@ -0,0 +1,4 @@
from hackingtool.cli import main
if __name__ == "__main__":
main()
+17 -5
View File
@@ -37,7 +37,7 @@ def _tokens(text: str) -> 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:
+1 -1
View File
@@ -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
+27 -11
View File
@@ -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()
+25 -17
View File
@@ -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:
+32 -4
View File
@@ -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)
+9 -2
View File
@@ -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
+1 -1
View File
@@ -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()
+3 -3
View File
@@ -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
+3 -4
View File
@@ -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,
+23 -10
View File
@@ -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"))]
+14 -3
View File
@@ -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 = []
+9 -2
View File
@@ -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
+1 -1
View File
@@ -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:
+7 -1
View File
@@ -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
+5 -3
View File
@@ -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)'}")
+12 -4
View File
@@ -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
+2 -1
View File
@@ -134,7 +134,8 @@ def sanitize(finding) -> dict:
def wrap_untrusted(text: str) -> str:
"""Frame untrusted tool output in the one <scan_data> delimiter used everywhere."""
return f"<scan_data>\n{text}\n</scan_data>"
safe = (text or "").replace("</scan_data>", "<\\/scan_data>") # can't break out of the fence
return f"<scan_data>\n{safe}\n</scan_data>"
def demo() -> None:
+131
View File
@@ -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
+9
View File
@@ -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"
+29
View File
@@ -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))
@@ -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])
+2 -2
View File
@@ -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 ./<file>.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):
+1 -1
View File
@@ -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"
View File
Whitespace-only changes.
+43
View File
@@ -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()
+61
View File
@@ -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;
}
+87
View File
@@ -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
+8
View File
@@ -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"
Whitespace-only changes.
+120
View File
@@ -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)
+29
View File
@@ -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))))
+68
View File
@@ -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)
+38
View File
@@ -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)))
+73
View File
@@ -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())
Whitespace-only changes.
+82
View File
@@ -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
@@ -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))
+32
View File
@@ -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)
+21
View File
@@ -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
+29
View File
@@ -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")
@@ -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()
+17
View File
@@ -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}"
+104
View File
@@ -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
+9
View File
@@ -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
+2 -2
View File
@@ -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 = {
+97
View File
@@ -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 </scan_data> injection")
assert out.count("</scan_data>") == 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]")
+5 -2
View File
@@ -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")
+82
View File
@@ -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"
+191
View File
@@ -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())
+134
View File
@@ -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())
+29
View File
@@ -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
+10
View File
@@ -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 == "○"
+22
View File
@@ -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())
+72
View File
@@ -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)
+30
View File
@@ -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
+106
View File
@@ -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())
+45
View File
@@ -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"
+75
View File
@@ -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())
+83
View File
@@ -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())
+112
View File
@@ -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())
+93
View File
@@ -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())
+67
View File
@@ -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())
+17
View File
@@ -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)
+41
View File
@@ -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