Skip to content
This repository was archived by the owner on Apr 22, 2026. It is now read-only.

Commit a344167

Browse files
committed
tui: in-UI image building, hub registry auto-recovery, clean hub-config
1 parent f192771 commit a344167

4 files changed

Lines changed: 296 additions & 515 deletions

File tree

fuzzforge-cli/src/fuzzforge_cli/tui/app.py

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ class FuzzForgeApp(App[None]):
9696
9797
/* Modal screens */
9898
AgentSetupScreen, AgentUnlinkScreen,
99-
HubManagerScreen, LinkHubScreen, CloneHubScreen {
99+
HubManagerScreen, LinkHubScreen, CloneHubScreen,
100+
BuildImageScreen {
100101
align: center middle;
101102
}
102103
@@ -130,6 +131,30 @@ class FuzzForgeApp(App[None]):
130131
overflow-y: auto;
131132
}
132133
134+
#build-dialog {
135+
width: 100;
136+
height: 80%;
137+
border: thick #4699fc;
138+
background: $surface;
139+
padding: 2 3;
140+
}
141+
142+
#build-log {
143+
height: 1fr;
144+
border: round $panel;
145+
margin: 1 0;
146+
}
147+
148+
#build-subtitle {
149+
color: $text-muted;
150+
margin-bottom: 1;
151+
}
152+
153+
#build-status {
154+
height: 1;
155+
margin-top: 1;
156+
}
157+
133158
.dialog-title {
134159
text-style: bold;
135160
text-align: center;
@@ -168,6 +193,7 @@ class FuzzForgeApp(App[None]):
168193
Binding("q", "quit", "Quit"),
169194
Binding("h", "manage_hubs", "Hub Manager"),
170195
Binding("r", "refresh", "Refresh"),
196+
Binding("enter", "select_row", "Select", show=False),
171197
]
172198

173199
def compose(self) -> ComposeResult:
@@ -194,7 +220,9 @@ def compose(self) -> ComposeResult:
194220
def on_mount(self) -> None:
195221
"""Populate tables on startup."""
196222
self._agent_rows: list[_AgentRow] = []
197-
self.query_one("#hub-panel").border_title = "Hub Servers"
223+
# hub row data: (server_name, image, hub_name) | None for group headers
224+
self._hub_rows: list[tuple[str, str, str] | None] = []
225+
self.query_one("#hub-panel").border_title = "Hub Servers [dim](Enter to build)[/dim]"
198226
self.query_one("#agents-panel").border_title = "AI Agents"
199227
self._refresh_agents()
200228
self._refresh_hub()
@@ -220,6 +248,7 @@ def _refresh_agents(self) -> None:
220248

221249
def _refresh_hub(self) -> None:
222250
"""Refresh the hub servers table, grouped by source hub."""
251+
self._hub_rows = []
223252
table = self.query_one("#hub-table", DataTable)
224253
table.clear(columns=True)
225254
table.add_columns("Server", "Image", "Hub", "Status")
@@ -275,6 +304,7 @@ def _refresh_hub(self) -> None:
275304
style="bold",
276305
)
277306
table.add_row(header, "", "", "")
307+
self._hub_rows.append(None) # group header — not selectable
278308

279309
# Tool rows
280310
for server, is_ready, status_text in statuses:
@@ -287,21 +317,25 @@ def _refresh_hub(self) -> None:
287317
elif is_ready:
288318
status_cell = Text("✓ Ready", style="green")
289319
else:
290-
status_cell = Text(f"✗ {status_text}", style="red")
320+
status_cell = Text(f"✗ {status_text}", style="red dim")
291321

292322
table.add_row(
293323
f" {name}",
294324
Text(image, style="dim"),
295325
hub_name,
296326
status_cell,
297327
)
328+
self._hub_rows.append((name, image, hub_name))
298329

299330
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
300-
"""Handle row selection on the agents table."""
301-
if event.data_table.id != "agents-table":
302-
return
303-
304-
idx = event.cursor_row
331+
"""Handle row selection on agents and hub tables."""
332+
if event.data_table.id == "agents-table":
333+
self._handle_agent_row(event.cursor_row)
334+
elif event.data_table.id == "hub-table":
335+
self._handle_hub_row(event.cursor_row)
336+
337+
def _handle_agent_row(self, idx: int) -> None:
338+
"""Open agent setup/unlink for the selected agent row."""
305339
if idx < 0 or idx >= len(self._agent_rows):
306340
return
307341

@@ -322,6 +356,32 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
322356
callback=self._on_agent_changed,
323357
)
324358

359+
def _handle_hub_row(self, idx: int) -> None:
360+
"""Open the build dialog for the selected hub tool row."""
361+
if idx < 0 or idx >= len(self._hub_rows):
362+
return
363+
row_data = self._hub_rows[idx]
364+
if row_data is None:
365+
return # group header row — ignore
366+
367+
server_name, image, hub_name = row_data
368+
if hub_name == "manual":
369+
self.notify("Manual servers must be built outside FuzzForge")
370+
return
371+
372+
from fuzzforge_cli.tui.screens.build_image import BuildImageScreen
373+
374+
self.push_screen(
375+
BuildImageScreen(server_name, image, hub_name),
376+
callback=self._on_image_built,
377+
)
378+
379+
def _on_image_built(self, success: bool) -> None:
380+
"""Refresh hub status after a build attempt."""
381+
self._refresh_hub()
382+
if success:
383+
self.notify("Image built successfully", severity="information")
384+
325385
def on_button_pressed(self, event: Button.Pressed) -> None:
326386
"""Handle button presses."""
327387
if event.button.id == "btn-hub-manager":

fuzzforge-cli/src/fuzzforge_cli/tui/helpers.py

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -287,20 +287,80 @@ def get_default_hubs_dir() -> Path:
287287
return get_fuzzforge_user_dir() / "hubs"
288288

289289

290+
def _discover_hub_dirs() -> list[Path]:
291+
"""Scan known hub directories for cloned repos.
292+
293+
Checks both the current global location (``~/.fuzzforge/hubs/``) and the
294+
legacy workspace-local location (``<cwd>/.fuzzforge/hubs/``) so that hubs
295+
cloned before the global-dir migration are still found.
296+
297+
:return: List of hub directory paths (each is a direct child with a ``.git``
298+
sub-directory).
299+
300+
"""
301+
candidates: list[Path] = []
302+
for base in (get_fuzzforge_user_dir() / "hubs", get_fuzzforge_dir() / "hubs"):
303+
if base.is_dir():
304+
for entry in base.iterdir():
305+
if entry.is_dir() and (entry / ".git").is_dir():
306+
candidates.append(entry)
307+
return candidates
308+
309+
290310
def load_hubs_registry() -> dict[str, Any]:
291311
"""Load the hubs registry from disk.
292312
313+
If the registry file does not exist, auto-recovers it by scanning known hub
314+
directories and rebuilding entries for any discovered hubs. This handles
315+
the migration from the old workspace-local ``<cwd>/.fuzzforge/hubs.json``
316+
path to the global ``~/.fuzzforge/hubs.json`` path, as well as any case
317+
where the registry was lost.
318+
293319
:return: Registry dict with ``hubs`` key containing a list of hub entries.
294320
295321
"""
296322
path = get_hubs_registry_path()
297-
if not path.exists():
323+
if path.exists():
324+
try:
325+
data: dict[str, Any] = json.loads(path.read_text())
326+
return data
327+
except (json.JSONDecodeError, OSError):
328+
pass
329+
330+
# Registry missing — attempt to rebuild from discovered hub directories.
331+
discovered = _discover_hub_dirs()
332+
if not discovered:
298333
return {"hubs": []}
334+
335+
hubs: list[dict[str, Any]] = []
336+
for hub_dir in discovered:
337+
name = hub_dir.name
338+
# Try to read the git remote URL
339+
git_url: str = ""
340+
try:
341+
import subprocess as _sp
342+
r = _sp.run(
343+
["git", "-C", str(hub_dir), "remote", "get-url", "origin"],
344+
check=False, capture_output=True, text=True, timeout=5,
345+
)
346+
if r.returncode == 0:
347+
git_url = r.stdout.strip()
348+
except Exception:
349+
pass
350+
hubs.append({
351+
"name": name,
352+
"path": str(hub_dir),
353+
"git_url": git_url,
354+
"is_default": name == FUZZFORGE_DEFAULT_HUB_NAME,
355+
})
356+
357+
registry: dict[str, Any] = {"hubs": hubs}
358+
# Persist so we don't re-scan on every load
299359
try:
300-
data: dict[str, Any] = json.loads(path.read_text())
301-
return data
302-
except (json.JSONDecodeError, OSError):
303-
return {"hubs": []}
360+
save_hubs_registry(registry)
361+
except OSError:
362+
pass
363+
return registry
304364

305365

306366
def save_hubs_registry(registry: dict[str, Any]) -> None:
@@ -566,3 +626,62 @@ def _remove_hub_servers_from_config(hub_name: str) -> int:
566626

567627
config_path.write_text(json.dumps(config, indent=2))
568628
return before - after
629+
630+
631+
def find_dockerfile_for_server(server_name: str, hub_name: str) -> Path | None:
632+
"""Find the Dockerfile for a hub server tool.
633+
634+
Looks up the hub path from the registry, then scans for
635+
``category/<server_name>/Dockerfile``.
636+
637+
:param server_name: Tool name (e.g. ``"nmap-mcp"``).
638+
:param hub_name: Hub name as stored in the registry.
639+
:return: Absolute path to the Dockerfile, or ``None`` if not found.
640+
641+
"""
642+
registry = load_hubs_registry()
643+
hub_entry = next(
644+
(h for h in registry.get("hubs", []) if h.get("name") == hub_name),
645+
None,
646+
)
647+
if not hub_entry:
648+
return None
649+
650+
hub_path = Path(hub_entry["path"])
651+
for dockerfile in hub_path.rglob("Dockerfile"):
652+
rel = dockerfile.relative_to(hub_path)
653+
parts = rel.parts
654+
if len(parts) == 3 and parts[1] == server_name:
655+
return dockerfile
656+
657+
return None
658+
659+
660+
def build_image(
661+
image: str,
662+
dockerfile: Path,
663+
*,
664+
engine: str | None = None,
665+
) -> subprocess.Popen[str]:
666+
"""Start a non-blocking ``docker/podman build`` subprocess.
667+
668+
Returns the running :class:`subprocess.Popen` object so the caller
669+
can stream ``stdout`` / ``stderr`` lines incrementally.
670+
671+
:param image: Image tag (e.g. ``"nmap-mcp:latest"``).
672+
:param dockerfile: Path to the ``Dockerfile``.
673+
:param engine: ``"docker"`` or ``"podman"`` (auto-detected if ``None``).
674+
:return: Running subprocess with merged stdout+stderr.
675+
676+
"""
677+
if engine is None:
678+
engine = os.environ.get("FUZZFORGE_ENGINE__TYPE", "docker").lower()
679+
engine = "podman" if engine == "podman" else "docker"
680+
681+
context_dir = str(dockerfile.parent)
682+
return subprocess.Popen(
683+
[engine, "build", "-t", image, context_dir],
684+
stdout=subprocess.PIPE,
685+
stderr=subprocess.STDOUT,
686+
text=True,
687+
)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Build-image modal screen for FuzzForge TUI.
2+
3+
Provides a modal dialog that runs ``docker/podman build`` for a single
4+
hub tool and streams the build log into a scrollable log area.
5+
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from pathlib import Path
11+
12+
from textual import work
13+
from textual.app import ComposeResult
14+
from textual.containers import Horizontal, Vertical
15+
from textual.screen import ModalScreen
16+
from textual.widgets import Button, Label, Log
17+
18+
from fuzzforge_cli.tui.helpers import build_image, find_dockerfile_for_server
19+
20+
21+
class BuildImageScreen(ModalScreen[bool]):
22+
"""Modal that builds a Docker/Podman image and streams the build log."""
23+
24+
BINDINGS = [("escape", "cancel", "Close")]
25+
26+
def __init__(self, server_name: str, image: str, hub_name: str) -> None:
27+
super().__init__()
28+
self._server_name = server_name
29+
self._image = image
30+
self._hub_name = hub_name
31+
32+
def compose(self) -> ComposeResult:
33+
"""Compose the build dialog layout."""
34+
with Vertical(id="build-dialog"):
35+
yield Label(f"Build {self._image}", classes="dialog-title")
36+
yield Label(
37+
f"Hub: {self._hub_name} • Tool: {self._server_name}",
38+
id="build-subtitle",
39+
)
40+
yield Log(id="build-log", auto_scroll=True)
41+
yield Label("", id="build-status")
42+
with Horizontal(classes="dialog-buttons"):
43+
yield Button("Close", variant="default", id="btn-close", disabled=True)
44+
45+
def on_mount(self) -> None:
46+
"""Start the build as soon as the screen is shown."""
47+
self._start_build()
48+
49+
def action_cancel(self) -> None:
50+
"""Only dismiss when the build is not running (Close button enabled)."""
51+
close_btn = self.query_one("#btn-close", Button)
52+
if not close_btn.disabled:
53+
self.dismiss(False)
54+
55+
def on_button_pressed(self, event: Button.Pressed) -> None:
56+
"""Handle Close button."""
57+
if event.button.id == "btn-close":
58+
self.dismiss(self._succeeded)
59+
60+
@work(thread=True)
61+
def _start_build(self) -> None:
62+
"""Run the build in a background thread and stream output."""
63+
self._succeeded = False
64+
log = self.query_one("#build-log", Log)
65+
status = self.query_one("#build-status", Label)
66+
67+
dockerfile = find_dockerfile_for_server(self._server_name, self._hub_name)
68+
if dockerfile is None:
69+
log.write_line(f"ERROR: Dockerfile not found for '{self._server_name}' in hub '{self._hub_name}'")
70+
status.update("[red]Build failed — Dockerfile not found[/red]")
71+
self.query_one("#btn-close", Button).disabled = False
72+
return
73+
74+
log.write_line(f"$ {self._get_engine()} build -t {self._image} {dockerfile.parent}")
75+
log.write_line("")
76+
77+
try:
78+
proc = build_image(self._image, dockerfile)
79+
except FileNotFoundError as exc:
80+
log.write_line(f"ERROR: {exc}")
81+
status.update("[red]Build failed — engine not found[/red]")
82+
self.query_one("#btn-close", Button).disabled = False
83+
return
84+
85+
assert proc.stdout is not None
86+
for line in proc.stdout:
87+
log.write_line(line.rstrip())
88+
89+
proc.wait()
90+
91+
if proc.returncode == 0:
92+
self._succeeded = True
93+
status.update(f"[green]✓ Built {self._image} successfully[/green]")
94+
else:
95+
status.update(f"[red]✗ Build failed (exit {proc.returncode})[/red]")
96+
97+
self.query_one("#btn-close", Button).disabled = False
98+
99+
@staticmethod
100+
def _get_engine() -> str:
101+
import os
102+
engine = os.environ.get("FUZZFORGE_ENGINE__TYPE", "docker").lower()
103+
return "podman" if engine == "podman" else "docker"

0 commit comments

Comments
 (0)