From 9f51c332527e4539edb21f6254c711d6266315a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 19:49:19 -0300 Subject: [PATCH 1/6] fix(install): align recommended profile and wizard contract --- scripts/forge_installkit.py | 2 +- src/apiforge/_installkit.py | 4 ++-- src/apiforge/ui/wizard.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/forge_installkit.py b/scripts/forge_installkit.py index 05cf04f8..018d2e4f 100644 --- a/scripts/forge_installkit.py +++ b/scripts/forge_installkit.py @@ -579,7 +579,7 @@ def _host_dirs(host: str, scope: str) -> list[str]: def profile_asset_kinds(profile: str) -> tuple[str, ...]: return { "minimal": ("config", "mcp", "marker", "state"), - "recommended": ("config", "mcp", "marker", "state", "skill"), + "recommended": ("config", "mcp", "marker", "state", "skill", "agent"), "full": ("config", "mcp", "marker", "state", "skill", "agent"), }[profile] diff --git a/src/apiforge/_installkit.py b/src/apiforge/_installkit.py index 62d9cd1b..54781ce6 100644 --- a/src/apiforge/_installkit.py +++ b/src/apiforge/_installkit.py @@ -33,7 +33,7 @@ _INSTALLKIT_VERSION = "1.0.0" # Filled by the vendor step (scripts/installkit/vendor.py) — the sha256 of # the canonical source body, so drift checks can compare vendored copies. -_SOURCE_SHA256 = "7d960bb98bbf7b1e" +_SOURCE_SHA256 = "9c230ee2fafd1261" SCHEMA_MANIFEST = "forge/InstallationManifest/v1" SCHEMA_RECEIPT = "forge/InstallReceipt/v1" @@ -579,7 +579,7 @@ def _host_dirs(host: str, scope: str) -> list[str]: def profile_asset_kinds(profile: str) -> tuple[str, ...]: return { "minimal": ("config", "mcp", "marker", "state"), - "recommended": ("config", "mcp", "marker", "state", "skill"), + "recommended": ("config", "mcp", "marker", "state", "skill", "agent"), "full": ("config", "mcp", "marker", "state", "skill", "agent"), }[profile] diff --git a/src/apiforge/ui/wizard.py b/src/apiforge/ui/wizard.py index 696cce29..4388fe0d 100644 --- a/src/apiforge/ui/wizard.py +++ b/src/apiforge/ui/wizard.py @@ -119,7 +119,7 @@ def run_wizard( _COMP_ORDER = ("skills", "agents", "mcp", "tui", "graph-studio") _profile_defaults = { "minimal": {"mcp", "tui"}, - "recommended": {"skills", "mcp", "tui", "graph-studio"}, + "recommended": {"skills", "agents", "mcp", "tui", "graph-studio"}, "full": {"skills", "agents", "mcp", "tui", "graph-studio"}, } comp_checked = _profile_defaults.get(profile, set()) From 16d2f657ed8ebc98858d30a4795b321e93520eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 20:03:41 -0300 Subject: [PATCH 2/6] fix(install): make state writes transactional --- scripts/forge_installkit.py | 38 +++++++++++++++++++++++++++++------ src/apiforge/_installkit.py | 40 ++++++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/scripts/forge_installkit.py b/scripts/forge_installkit.py index 018d2e4f..158d95d1 100644 --- a/scripts/forge_installkit.py +++ b/scripts/forge_installkit.py @@ -725,6 +725,13 @@ def apply_install(ctx: InstallContext, *, approved: bool = False) -> dict[str, A markers: list[dict[str, Any]] = [] checks: list[dict[str, Any]] = [] ledger = ctx.ledger or Ledger.load(ctx.state_dir) + ledger_before = ledger.path.read_bytes() if ledger.path.exists() else None + component_file = components_path(ctx.state_dir) if ctx.options else None + component_before = ( + component_file.read_bytes() + if component_file is not None and component_file.exists() + else None + ) # §14.3 journal — every write is either a creation (removable) or an # overwrite with a pre-state snapshot; on failure the managed half is # reverted before the error propagates. @@ -867,12 +874,31 @@ def _snapshot(target: Path, rel: str) -> None: E_WRITE, f"install failed at write stage; managed writes reverted: {exc}" ) from exc - ledger.save() - if ctx.options: - ctx.state_dir.mkdir(parents=True, exist_ok=True) - _atomic_write( - components_path(ctx.state_dir), _json_bytes(dict(ctx.options)) - ) + try: + ledger.save() + if ctx.options: + ctx.state_dir.mkdir(parents=True, exist_ok=True) + _atomic_write(component_file, _json_bytes(dict(ctx.options))) + except InstallError: + _rollback_writes(ctx, created, backups) + raise + except Exception as exc: + _rollback_writes(ctx, created, backups) + try: + if ledger_before is None: + ledger.path.unlink(missing_ok=True) + else: + _atomic_write(ledger.path, ledger_before) + if component_file is not None: + if component_before is None: + component_file.unlink(missing_ok=True) + else: + _atomic_write(component_file, component_before) + except OSError: + pass + raise InstallError( + E_WRITE, f"install failed at state stage; managed writes reverted: {exc}" + ) from exc written = sum(1 for f in files if f.get("action") in ("created", "updated")) checks.append({"id": "files-written", "status": "PASS", "detail": f"{written} writes"}) receipt = { diff --git a/src/apiforge/_installkit.py b/src/apiforge/_installkit.py index 54781ce6..d61de8c7 100644 --- a/src/apiforge/_installkit.py +++ b/src/apiforge/_installkit.py @@ -33,7 +33,7 @@ _INSTALLKIT_VERSION = "1.0.0" # Filled by the vendor step (scripts/installkit/vendor.py) — the sha256 of # the canonical source body, so drift checks can compare vendored copies. -_SOURCE_SHA256 = "9c230ee2fafd1261" +_SOURCE_SHA256 = "866b66c20f245e7c" SCHEMA_MANIFEST = "forge/InstallationManifest/v1" SCHEMA_RECEIPT = "forge/InstallReceipt/v1" @@ -725,6 +725,13 @@ def apply_install(ctx: InstallContext, *, approved: bool = False) -> dict[str, A markers: list[dict[str, Any]] = [] checks: list[dict[str, Any]] = [] ledger = ctx.ledger or Ledger.load(ctx.state_dir) + ledger_before = ledger.path.read_bytes() if ledger.path.exists() else None + component_file = components_path(ctx.state_dir) if ctx.options else None + component_before = ( + component_file.read_bytes() + if component_file is not None and component_file.exists() + else None + ) # §14.3 journal — every write is either a creation (removable) or an # overwrite with a pre-state snapshot; on failure the managed half is # reverted before the error propagates. @@ -867,12 +874,31 @@ def _snapshot(target: Path, rel: str) -> None: E_WRITE, f"install failed at write stage; managed writes reverted: {exc}" ) from exc - ledger.save() - if ctx.options: - ctx.state_dir.mkdir(parents=True, exist_ok=True) - _atomic_write( - components_path(ctx.state_dir), _json_bytes(dict(ctx.options)) - ) + try: + ledger.save() + if ctx.options: + ctx.state_dir.mkdir(parents=True, exist_ok=True) + _atomic_write(component_file, _json_bytes(dict(ctx.options))) + except InstallError: + _rollback_writes(ctx, created, backups) + raise + except Exception as exc: + _rollback_writes(ctx, created, backups) + try: + if ledger_before is None: + ledger.path.unlink(missing_ok=True) + else: + _atomic_write(ledger.path, ledger_before) + if component_file is not None: + if component_before is None: + component_file.unlink(missing_ok=True) + else: + _atomic_write(component_file, component_before) + except OSError: + pass + raise InstallError( + E_WRITE, f"install failed at state stage; managed writes reverted: {exc}" + ) from exc written = sum(1 for f in files if f.get("action") in ("created", "updated")) checks.append({"id": "files-written", "status": "PASS", "detail": f"{written} writes"}) if "codex" in ctx.hosts and ctx.scope == "project": From 22ea2451f384e377a8d7bc4c1bc6d0b7d6a1f323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 20:12:43 -0300 Subject: [PATCH 3/6] feat(install): route bare CLI to guided wizard --- docs/experience/automation-mode.md | 4 +-- docs/experience/installation-wizard.md | 10 +++--- src/apiforge/cli_install.py | 50 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/docs/experience/automation-mode.md b/docs/experience/automation-mode.md index a1711723..7d8cba3e 100644 --- a/docs/experience/automation-mode.md +++ b/docs/experience/automation-mode.md @@ -5,7 +5,7 @@ Every interactive surface degrades to a deterministic, scriptable path. | Situation | Behavior | |---|---| | Non-TTY bare `apiforge` | product summary, exit 0 | -| Non-TTY `apiforge install` | usage error pointing at `install apply` | +| Non-TTY `apiforge install` | structured approval refusal; no prompt, no writes | | Interactive fn called headless | `NonInteractive` raised — never blocks | | `--dry-run` | real plan, zero writes | | `--yes` | explicit approval for writes | @@ -14,7 +14,7 @@ Every interactive surface degrades to a deterministic, scriptable path. CI pattern: ```bash -apiforge install apply --scope project --profile minimal --yes +apiforge install --scope project --profile minimal --yes apiforge doctor ``` diff --git a/docs/experience/installation-wizard.md b/docs/experience/installation-wizard.md index fd835970..abe19a83 100644 --- a/docs/experience/installation-wizard.md +++ b/docs/experience/installation-wizard.md @@ -1,8 +1,8 @@ # Installation wizard Bare `apiforge install` on a TTY opens the guided wizard. On a non-TTY it -exits with a usage error pointing at `install apply` — scripts keep the -flag contract. +returns a structured approval refusal without reading stdin or writing files. +Explicit flags keep the deterministic contract. ## Steps @@ -24,10 +24,10 @@ flag contract. Non-interactive equivalent: ```bash -apiforge install apply --scope project --profile recommended --host all --dry-run -apiforge install apply --scope project --profile recommended --host all --yes +apiforge install --scope project --profile recommended --host all --dry-run +apiforge install --scope project --profile recommended --host all --yes # optional components: -apiforge install apply --yes --components skills,mcp,tui,graph-studio +apiforge install --yes --components skills,mcp,tui,graph-studio apiforge install doctor ``` diff --git a/src/apiforge/cli_install.py b/src/apiforge/cli_install.py index c65f3a2f..b3e0608d 100644 --- a/src/apiforge/cli_install.py +++ b/src/apiforge/cli_install.py @@ -77,6 +77,31 @@ def _call(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: raise AssertionError("unreachable") +def _bare_install_requested( + ctx: typer.Context, + *, + scope: str, + host: str, + profile: str, + root: Path | None, + yes: bool, + dry_run: bool, + components: str | None, +) -> bool: + """Detect exact default invocation without guessing explicit flags.""" + if (scope, host, profile, root, yes, dry_run, components) != ( + "project", "all", "recommended", None, False, False, None + ): + return False + source = getattr(ctx, "get_parameter_source", None) + if source is None: + return True + return all( + getattr(source(name), "name", "") == "DEFAULT" + for name in ("scope", "host", "profile", "root", "yes", "dry_run", "components") + ) + + @install_app.callback() def install_apply( ctx: typer.Context, @@ -99,6 +124,31 @@ def install_apply( import sys comps = tuple(c.strip() for c in components.split(",") if c.strip()) if components else None + if ( + _bare_install_requested( + ctx, + scope=scope, + host=host, + profile=profile, + root=root, + yes=yes, + dry_run=dry_run, + components=components, + ) + and sys.stdin.isatty() + and sys.stdout.isatty() + ): + from apiforge.ui.kit import UIContext + from apiforge.ui.wizard import run_wizard + + raise typer.Exit( + run_wizard( + forge_name="api-forge", + install_fn=lambda **kw: service.install(root=None, **kw), + doctor_fn=lambda **kw: service.doctor(**kw), + ctx=UIContext.detect(), + ) + ) approved = yes if not yes and not dry_run and sys.stdin.isatty() and sys.stdout.isatty(): # Interactive approval: render the real plan, confirm once, From 2ce75e8e83a5bd7fbb37fff3eb17214186ec1ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 20:28:03 -0300 Subject: [PATCH 4/6] feat(tui): add filtering and pagination controls --- src/apiforge/ui/app.py | 60 ++++++++++++++++++++++++++++++++++++--- src/apiforge/ui/screen.py | 6 ++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index c86250b0..b0a4ec86 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -88,6 +88,7 @@ class _Pane: selected: int = 0 executing: bool = False log: list[str] = field(default_factory=list) + scroll: int = 0 _HELP = [("up/dn", "nav"), ("enter", "open/run"), ("r", "refresh"), @@ -129,6 +130,7 @@ def __init__(self, ctx: UIContext, *, forge_id: str, title: str, self.focus = "nav" # nav | content self.pane = _Pane() self.filter = "" + self.filter_active = False self.show_help = False self._out = out self._load() @@ -158,7 +160,8 @@ def render(self, buf: Buffer) -> None: right=self.version) nav_w = max(22, min(34, buf.width // 4)) ix, iy, iw, ih = panel(buf, 0, 2, nav_w, buf.height - 5, - title="menu", focused=self.focus == "nav", + title=f"menu /{self.filter}" if self.filter_active or self.filter else "menu", + focused=self.focus == "nav", theme=th) vis = self._visible_entries() for row, (i, e) in enumerate(vis[:ih]): @@ -203,7 +206,7 @@ def _render_content(self, buf: Buffer, x: int, y: int, w: int, h: int) -> None: lines = d.lines if isinstance(d, TextData) else ( [f"action: {d.title}"] + _doc_lines(d) + ["", "press enter to execute"] + self.pane.log) - for i, line in enumerate(lines[:h]): + for i, line in enumerate(lines[self.pane.scroll:self.pane.scroll + h]): buf.draw(y + i, x, line) def _render_help(self, buf: Buffer) -> None: @@ -228,6 +231,8 @@ def _render_help(self, buf: Buffer) -> None: def on_key(self, key: str) -> str | None: if key == "ctrl-c": return "quit" + if self.filter_active: + return self._filter_key(key) if self.show_help: self.show_help = False return None @@ -235,16 +240,41 @@ def on_key(self, key: str) -> str | None: self.show_help = True return None if key == "/": - self.filter = "" # placeholder toggles filter line; keep simple + self.filter_active = True self.focus = "nav" return None - if key == "tab": + if key in ("tab", "backtab"): self.focus = "content" if self.focus == "nav" else "nav" return None if self.focus == "nav": return self._nav_key(key) return self._content_key(key) + def _filter_key(self, key: str) -> str | None: + if key in ("enter", "tab", "backtab"): + self.filter_active = False + return None + if key == "esc": + self.filter = "" + self.filter_active = False + self._sync_filter_selection() + return None + if key == "backspace": + self.filter = self.filter[:-1] + self._sync_filter_selection() + return None + char = key[5:] if key.startswith("char:") else key + if len(char) == 1 and ord(char) >= 32: + self.filter += char + self._sync_filter_selection() + return None + + def _sync_filter_selection(self) -> None: + visible = self._visible_entries() + if visible and self.nav_idx not in {i for i, _ in visible}: + self.nav_idx = visible[0][0] + self._load() + def _nav_key(self, key: str) -> str | None: vis = self._visible_entries() if key in ("up", "k"): @@ -282,11 +312,33 @@ def _content_key(self, key: str) -> str | None: self.pane.selected = max(0, self.pane.selected - 1) elif key in ("down", "j"): self.pane.selected = min(len(d.rows) - 1, self.pane.selected + 1) + elif key == "pageup": + self.pane.selected = max(0, self.pane.selected - 10) + elif key == "pagedown": + self.pane.selected = min(len(d.rows) - 1, self.pane.selected + 10) + elif key == "home": + self.pane.selected = 0 + elif key == "end": + self.pane.selected = len(d.rows) - 1 elif key == "enter" and d.on_enter: d.on_enter(d.rows[self.pane.selected]) self._load() elif isinstance(d, ActionData) and key == "enter": return "exec" # loop decides suspend vs in-place + elif isinstance(d, (TextData, ActionData)): + line_count = len(d.lines) if isinstance(d, TextData) else len(self.pane.log) + 8 + if key in ("up", "k"): + self.pane.scroll = max(0, self.pane.scroll - 1) + elif key in ("down", "j"): + self.pane.scroll = min(max(0, line_count - 1), self.pane.scroll + 1) + elif key == "pageup": + self.pane.scroll = max(0, self.pane.scroll - 10) + elif key == "pagedown": + self.pane.scroll = min(max(0, line_count - 1), self.pane.scroll + 10) + elif key == "home": + self.pane.scroll = 0 + elif key == "end": + self.pane.scroll = max(0, line_count - 1) return None def _execute(self, d: ActionData) -> None: diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 1d87da49..72a1ad2d 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -330,7 +330,8 @@ def __exit__(self, *exc) -> bool: _ESC = {"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", - "\x1b[D": "left", "\x1b[H": "home", "\x1b[F": "end"} + "\x1b[D": "left", "\x1b[H": "home", "\x1b[F": "end", + "\x1b[5~": "pageup", "\x1b[6~": "pagedown", "\x1b[Z": "backtab"} _KEYS = {"\r": "enter", "\n": "enter", " ": "space", "\t": "tab", "\x1b": "esc", "\x7f": "backspace", "\x03": "ctrl-c"} @@ -345,7 +346,8 @@ def read_key(timeout: float = 0.05) -> str | None: ch = msvcrt.getwch() if ch in ("\x00", "\xe0"): # special key prefix return {"H": "up", "P": "down", "M": "right", - "K": "left", "G": "home", "O": "end"}.get(msvcrt.getwch()) + "K": "left", "G": "home", "O": "end", + "I": "pageup", "Q": "pagedown"}.get(msvcrt.getwch()) return _KEYS.get(ch, ch) # POSIX — select-based poll import select From 9eb85dfa61c63f50320ffa2ad5ef5f940008b385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 21:01:15 -0300 Subject: [PATCH 5/6] style(ci): format install and TUI changes --- src/apiforge/_installkit.py | 8 +- src/apiforge/cli_install.py | 8 +- src/apiforge/ui/app.py | 110 +++++++++++++++++++-------- src/apiforge/ui/screen.py | 147 ++++++++++++++++++++++++++---------- 4 files changed, 196 insertions(+), 77 deletions(-) diff --git a/src/apiforge/_installkit.py b/src/apiforge/_installkit.py index d61de8c7..1cff2980 100644 --- a/src/apiforge/_installkit.py +++ b/src/apiforge/_installkit.py @@ -910,7 +910,7 @@ def _snapshot(target: Path, rel: str) -> None: "id": "mcp-scope:codex", "status": "NOT_APPLICABLE", "detail": "codex does not read project .mcp.json; MCP needs " - "--scope user or `codex mcp add`", + "--scope user or `codex mcp add`", } ) receipt = { @@ -1054,9 +1054,11 @@ def doctor(ctx: InstallContext) -> dict[str, Any]: "id": "forge-portal", "status": "PASS" if found else "NOT_APPLICABLE", "detail": presence["install_root"] - or ("vendored assets only — no local forge-portal install found" + or ( + "vendored assets only — no local forge-portal install found" if not found - else presence["manifest"]), + else presence["manifest"] + ), "repairable": False, } ) diff --git a/src/apiforge/cli_install.py b/src/apiforge/cli_install.py index b3e0608d..a794164f 100644 --- a/src/apiforge/cli_install.py +++ b/src/apiforge/cli_install.py @@ -90,7 +90,13 @@ def _bare_install_requested( ) -> bool: """Detect exact default invocation without guessing explicit flags.""" if (scope, host, profile, root, yes, dry_run, components) != ( - "project", "all", "recommended", None, False, False, None + "project", + "all", + "recommended", + None, + False, + False, + None, ): return False source = getattr(ctx, "get_parameter_source", None) diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index b0a4ec86..a3b3bb65 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -58,6 +58,7 @@ class DocMeta: """Contextual doc metadata for an action (Forge Knowledge §21): description, prerequisites, example, risk, expected result and the canonical doc — a pointer, never an inlined manual.""" + description: str = "" prerequisites: str = "" example: str = "" @@ -91,8 +92,14 @@ class _Pane: scroll: int = 0 -_HELP = [("up/dn", "nav"), ("enter", "open/run"), ("r", "refresh"), - ("/", "filter"), ("?", "help"), ("q", "back/quit")] +_HELP = [ + ("up/dn", "nav"), + ("enter", "open/run"), + ("r", "refresh"), + ("/", "filter"), + ("?", "help"), + ("q", "back/quit"), +] def _doc_lines(d: "ActionData") -> list[str]: @@ -117,9 +124,16 @@ def _doc_lines(d: "ActionData") -> list[str]: class ForgeApp: """Two-pane full-screen app: left nav, right content.""" - def __init__(self, ctx: UIContext, *, forge_id: str, title: str, - entries: list[Entry], version: str = "", - out: TextIO | None = None): + def __init__( + self, + ctx: UIContext, + *, + forge_id: str, + title: str, + entries: list[Entry], + version: str = "", + out: TextIO | None = None, + ): self.ctx = ctx self.forge_id = forge_id self.title = title @@ -142,40 +156,52 @@ def _load(self) -> None: try: data = entry.provider() except Exception as exc: # real error state, never a lie - data = StateSpec(kind="error", title="provider failed", - detail=str(exc) or exc.__class__.__name__, - hint="press r to retry") + data = StateSpec( + kind="error", + title="provider failed", + detail=str(exc) or exc.__class__.__name__, + hint="press r to retry", + ) self.pane = _Pane(data=data) def _visible_entries(self) -> list[tuple[int, Entry]]: f = self.filter.lower() - return [(i, e) for i, e in enumerate(self.entries) - if not f or f in e.label.lower()] + return [(i, e) for i, e in enumerate(self.entries) if not f or f in e.label.lower()] # --------------------------------------------------------- render def render(self, buf: Buffer) -> None: ctx, th = self.ctx, self.theme - header(buf, self.forge_id, self.title, theme=th, - right=self.version) + header(buf, self.forge_id, self.title, theme=th, right=self.version) nav_w = max(22, min(34, buf.width // 4)) - ix, iy, iw, ih = panel(buf, 0, 2, nav_w, buf.height - 5, - title=f"menu /{self.filter}" if self.filter_active or self.filter else "menu", - focused=self.focus == "nav", - theme=th) + ix, iy, iw, ih = panel( + buf, + 0, + 2, + nav_w, + buf.height - 5, + title=f"menu /{self.filter}" if self.filter_active or self.filter else "menu", + focused=self.focus == "nav", + theme=th, + ) vis = self._visible_entries() for row, (i, e) in enumerate(vis[:ih]): if row >= ih: break marker = "> " if i == self.nav_idx else " " - style = th.focus if (i == self.nav_idx and self.focus == "nav" - and ctx.color) else "" + style = th.focus if (i == self.nav_idx and self.focus == "nav" and ctx.color) else "" buf.draw(iy + row, ix - 1, marker + e.label, style=style) # content pane - cx, cy, cw, ch = panel(buf, nav_w, 2, buf.width - nav_w, - buf.height - 5, - title=self.entries[self.nav_idx].label, - focused=self.focus == "content", theme=th) + cx, cy, cw, ch = panel( + buf, + nav_w, + 2, + buf.width - nav_w, + buf.height - 5, + title=self.entries[self.nav_idx].label, + focused=self.focus == "content", + theme=th, + ) self._render_content(buf, cx, cy, cw, ch) if self.show_help: self._render_help(buf) @@ -194,19 +220,37 @@ def _render_content(self, buf: Buffer, x: int, y: int, w: int, h: int) -> None: if isinstance(d, TableData): if not d.rows: sub = Buffer(buf.ctx, w, h) - state_view(sub, StateSpec(kind="empty", title="no rows", - detail="nothing to show"), - theme=self.theme) + state_view( + sub, + StateSpec(kind="empty", title="no rows", detail="nothing to show"), + theme=self.theme, + ) for row, line in enumerate(sub.render()): buf.draw(y + row, x, line) return - table(buf, x + 1, y, w - 2, h, columns=d.columns, rows=d.rows, - selected=self.pane.selected, theme=self.theme) + table( + buf, + x + 1, + y, + w - 2, + h, + columns=d.columns, + rows=d.rows, + selected=self.pane.selected, + theme=self.theme, + ) return - lines = d.lines if isinstance(d, TextData) else ( - [f"action: {d.title}"] + _doc_lines(d) + - ["", "press enter to execute"] + self.pane.log) - for i, line in enumerate(lines[self.pane.scroll:self.pane.scroll + h]): + lines = ( + d.lines + if isinstance(d, TextData) + else ( + [f"action: {d.title}"] + + _doc_lines(d) + + ["", "press enter to execute"] + + self.pane.log + ) + ) + for i, line in enumerate(lines[self.pane.scroll : self.pane.scroll + h]): buf.draw(y + i, x, line) def _render_help(self, buf: Buffer) -> None: @@ -364,6 +408,7 @@ def run(self) -> int: if not (ctx.interactive and ctx.ansi): raise NonInteractive("TUI requires an ANSI TTY") import os + with Screen(ctx, out=self._out or sys.stdout) as scr: size = (0, 0) while True: @@ -375,8 +420,7 @@ def run(self) -> int: pass if now != size: size = now - self.ctx = UIContext(**{**ctx.__dict__, - "width": size[0], "height": size[1]}) + self.ctx = UIContext(**{**ctx.__dict__, "width": size[0], "height": size[1]}) ctx = self.ctx buf = Buffer(ctx, size[0], size[1]) if size[0] < MIN_WIDTH or size[1] < MIN_HEIGHT: diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 72a1ad2d..2f2fd80f 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -66,16 +66,33 @@ def for_forge(forge_id: str) -> Theme: _STATE_ICON_UNI = { - "empty": "○", "loading": "…", "ready": "●", "ok": "✓", - "error": "✗", "warn": "▲", "disconnected": "⚡", "too_small": "!", + "empty": "○", + "loading": "…", + "ready": "●", + "ok": "✓", + "error": "✗", + "warn": "▲", + "disconnected": "⚡", + "too_small": "!", } _STATE_ICON_ASCII = { - "empty": "o", "loading": "...", "ready": "*", "ok": "+", - "error": "x", "warn": "!", "disconnected": "x", "too_small": "!", + "empty": "o", + "loading": "...", + "ready": "*", + "ok": "+", + "error": "x", + "warn": "!", + "disconnected": "x", + "too_small": "!", } _STATE_COLOR = { - "empty": "dim", "loading": "info", "ready": "ok", "ok": "ok", - "error": "err", "warn": "warn", "disconnected": "warn", + "empty": "dim", + "loading": "info", + "ready": "ok", + "ok": "ok", + "error": "err", + "warn": "warn", + "disconnected": "warn", "too_small": "warn", } @@ -143,8 +160,7 @@ def render(self) -> list[str]: out.append("") continue # later draws overlay earlier: sort by x implicit in draw order - line = "".join(_c(self.ctx, s.style, s.text) if s.style else s.text - for s in spans) + line = "".join(_c(self.ctx, s.style, s.text) if s.style else s.text for s in spans) out.append(line[: self.width * 4]) # ansi-safe bound; clips at SGR return out @@ -152,8 +168,17 @@ def render(self) -> list[str]: # ---------------------------------------------------------------- layout -def panel(buf: Buffer, x: int, y: int, w: int, h: int, *, title: str = "", - focused: bool = False, theme: Theme) -> tuple[int, int, int, int]: +def panel( + buf: Buffer, + x: int, + y: int, + w: int, + h: int, + *, + title: str = "", + focused: bool = False, + theme: Theme, +) -> tuple[int, int, int, int]: """Draw a bordered panel; returns inner (x, y, w, h) for content.""" ctx = buf.ctx b = box(ctx) @@ -162,9 +187,15 @@ def panel(buf: Buffer, x: int, y: int, w: int, h: int, *, title: str = "", if w < 3 or h < 3: return x, y, w, h cap = f" {title} " if title else "" - top = tl + hh * (w - 2 - cell_width(cap)) + cap if False else ( - tl + cap + hh * max(0, w - 2 - cell_width(cap)) + tr - if cell_width(cap) <= w - 2 else tl + hh * (w - 2) + tr) + top = ( + tl + hh * (w - 2 - cell_width(cap)) + cap + if False + else ( + tl + cap + hh * max(0, w - 2 - cell_width(cap)) + tr + if cell_width(cap) <= w - 2 + else tl + hh * (w - 2) + tr + ) + ) buf.draw(y, x, top, style=border_style) for row in range(1, h - 1): buf.draw(y + row, x, vv, style=border_style) @@ -177,13 +208,11 @@ def key_bar(buf: Buffer, bindings: Iterable[tuple[str, str]], *, theme: Theme) - """Footer line: ``key action · key action``.""" y = buf.height - 1 buf.hline(y - 1, style="2") - parts = " · ".join( - f"{_c(buf.ctx, '1' if buf.ctx.color else '', k)} {v}" for k, v in bindings) + parts = " · ".join(f"{_c(buf.ctx, '1' if buf.ctx.color else '', k)} {v}" for k, v in bindings) buf.draw(y, 1, re.sub(r"\x1b\[[0-9;]*m", "", parts) if not buf.ctx.color else parts) -def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, - right: str = "") -> None: +def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, right: str = "") -> None: """2-row header: brand line + accent rule.""" ctx = buf.ctx brand = f" {forge.upper()} " @@ -203,10 +232,10 @@ def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, class StateSpec: """A screen's real state — views render this, never hardcode.""" - kind: str = "ready" # empty|loading|ready|error|disconnected|too_small|warn + kind: str = "ready" # empty|loading|ready|error|disconnected|too_small|warn title: str = "" detail: str = "" - hint: str = "" # recovery hint ("press r to retry") + hint: str = "" # recovery hint ("press r to retry") def state_view(buf: Buffer, state: StateSpec, *, theme: Theme) -> None: @@ -218,19 +247,22 @@ def state_view(buf: Buffer, state: StateSpec, *, theme: Theme) -> None: line = f"{icon} {state.title or state.kind}" buf.draw(cy - 1, max(1, (buf.width - cell_width(line)) // 2), line, style=color) if state.detail: - buf.draw(cy, max(1, (buf.width - cell_width(state.detail)) // 2), - state.detail, style="dim") + buf.draw(cy, max(1, (buf.width - cell_width(state.detail)) // 2), state.detail, style="dim") if state.hint: - buf.draw(cy + 1, max(1, (buf.width - cell_width(state.hint)) // 2), - state.hint) + buf.draw(cy + 1, max(1, (buf.width - cell_width(state.hint)) // 2), state.hint) def too_small(buf: Buffer, *, theme: Theme) -> None: - state_view(buf, StateSpec( - kind="too_small", - title=f"terminal too small ({buf.width}x{buf.height})", - detail=f"need at least {MIN_WIDTH}x{MIN_HEIGHT}", - hint="resize to continue"), theme=theme) + state_view( + buf, + StateSpec( + kind="too_small", + title=f"terminal too small ({buf.width}x{buf.height})", + detail=f"need at least {MIN_WIDTH}x{MIN_HEIGHT}", + hint="resize to continue", + ), + theme=theme, + ) # ---------------------------------------------------------------- table @@ -243,9 +275,18 @@ class Column: width: int | None = None # None = flexible -def table(buf: Buffer, x: int, y: int, w: int, h: int, *, - columns: list[Column], rows: list[dict[str, str]], - selected: int = 0, theme: Theme) -> None: +def table( + buf: Buffer, + x: int, + y: int, + w: int, + h: int, + *, + columns: list[Column], + rows: list[dict[str, str]], + selected: int = 0, + theme: Theme, +) -> None: """Selectable table: fixed columns, flexible last column, scrolls.""" ctx = buf.ctx fixed = sum(c.width for c in columns if c.width) + 2 * (len(columns) - 1) @@ -259,7 +300,7 @@ def table(buf: Buffer, x: int, y: int, w: int, h: int, *, cx += cw + 2 body_h = h - 2 start = max(0, min(selected - body_h + 1, len(rows) - body_h)) - for i, row in enumerate(rows[start:start + body_h]): + for i, row in enumerate(rows[start : start + body_h]): idx = start + i cx = x marker = ">" if idx == selected else " " @@ -317,6 +358,7 @@ def __enter__(self) -> _RawMode: if os.name != "nt" and sys.stdin.isatty(): import termios import tty + self._fd = sys.stdin.fileno() self._saved = termios.tcgetattr(self._fd) tty.setcbreak(self._fd) @@ -325,32 +367,57 @@ def __enter__(self) -> _RawMode: def __exit__(self, *exc) -> bool: if getattr(self, "_saved", None) is not None: import termios + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved) return False -_ESC = {"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", - "\x1b[D": "left", "\x1b[H": "home", "\x1b[F": "end", - "\x1b[5~": "pageup", "\x1b[6~": "pagedown", "\x1b[Z": "backtab"} -_KEYS = {"\r": "enter", "\n": "enter", " ": "space", "\t": "tab", - "\x1b": "esc", "\x7f": "backspace", "\x03": "ctrl-c"} +_ESC = { + "\x1b[A": "up", + "\x1b[B": "down", + "\x1b[C": "right", + "\x1b[D": "left", + "\x1b[H": "home", + "\x1b[F": "end", + "\x1b[5~": "pageup", + "\x1b[6~": "pagedown", + "\x1b[Z": "backtab", +} +_KEYS = { + "\r": "enter", + "\n": "enter", + " ": "space", + "\t": "tab", + "\x1b": "esc", + "\x7f": "backspace", + "\x03": "ctrl-c", +} def read_key(timeout: float = 0.05) -> str | None: """Non-blocking key read; returns a normalized name or None.""" if os.name == "nt": import msvcrt + if not msvcrt.kbhit(): time.sleep(timeout) return None ch = msvcrt.getwch() if ch in ("\x00", "\xe0"): # special key prefix - return {"H": "up", "P": "down", "M": "right", - "K": "left", "G": "home", "O": "end", - "I": "pageup", "Q": "pagedown"}.get(msvcrt.getwch()) + return { + "H": "up", + "P": "down", + "M": "right", + "K": "left", + "G": "home", + "O": "end", + "I": "pageup", + "Q": "pagedown", + }.get(msvcrt.getwch()) return _KEYS.get(ch, ch) # POSIX — select-based poll import select + r, _, _ = select.select([sys.stdin], [], [], timeout) if not r: return None From 338e10dea6ae12601a701b0023783085f12aade8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 21:15:45 -0300 Subject: [PATCH 6/6] fix(install): synchronize wizard execution profile contract --- src/apiforge/ui/i18n.py | 2 ++ src/apiforge/ui/wizard.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/apiforge/ui/i18n.py b/src/apiforge/ui/i18n.py index 7f437e47..3b193e82 100644 --- a/src/apiforge/ui/i18n.py +++ b/src/apiforge/ui/i18n.py @@ -23,6 +23,7 @@ "toggle_hint": "space toggles, enter confirms; j/k moves", "install_scope_q": "Where do you want to install?", "install_profile_q": "Installation profile?", + "install_gateway_profile_q": "Execution profile?", "install_hosts_q": "Configure AI hosts?", "install_components_q": "Optional components?", "review": "Review installation", @@ -78,6 +79,7 @@ "toggle_hint": "espaço alterna, enter confirma; j/k move", "install_scope_q": "Onde instalar?", "install_profile_q": "Perfil de instalação?", + "install_gateway_profile_q": "Perfil de execução?", "install_hosts_q": "Configurar hosts de IA?", "install_components_q": "Componentes opcionais?", "review": "Revisar instalação", diff --git a/src/apiforge/ui/wizard.py b/src/apiforge/ui/wizard.py index 4388fe0d..4ffd1368 100644 --- a/src/apiforge/ui/wizard.py +++ b/src/apiforge/ui/wizard.py @@ -35,6 +35,12 @@ "minimal": "Economy", "full": "Full", } +_GATEWAY_PROFILES = ("economy", "balanced", "deep") +_GATEWAY_LABELS = { + "economy": "Economy (lowest context cost)", + "balanced": "Balanced (recommended)", + "deep": "Deep (maximum verification)", +} _SCOPE_LABELS = { "project": "Current repository", "workspace": "Entire workspace", @@ -105,6 +111,16 @@ def run_wizard( return 130 profile = _PROFILES[idx] + # --- execution profile -------------------------------------------------- + idx = select( + i18n.t("install_gateway_profile_q", ctx.language), + [_GATEWAY_LABELS[p] for p in _GATEWAY_PROFILES], + ctx=ctx, + ) + if idx is None: + return 130 + gateway_profile = _GATEWAY_PROFILES[idx] + # --- optional components --------------------------------------------------- # The profile decides the default; the operator can narrow or extend it. # "graph-studio"/"tui" are runtime flags persisted to components.json — @@ -157,7 +173,12 @@ def run_wizard( # --- review (real dry-run plan) ------------------------------------------ # Contrato explícito: install_fn aceita `components` — wrappers **kw # encaminham sem inspeção de assinatura (GAP-002). - kwargs: dict[str, Any] = {"scope": scope, "profile": profile, "components": components} + kwargs: dict[str, Any] = { + "scope": scope, + "profile": profile, + "gateway_profile": gateway_profile, + "components": components, + } try: # `none` é opt-out explícito — zero hosts escolhidos nunca vira # "all" (GAP-003); csv de subconjunto é contrato válido (GAP-003b). @@ -175,6 +196,7 @@ def run_wizard( [ ("scope", scope), ("profile", profile), + ("execution", gateway_profile), ("components", ", ".join(components) or "none"), ("hosts", host_arg or "none"), ("managed writes", str(len(planned) if isinstance(planned, list) else planned)), @@ -204,7 +226,10 @@ def run_wizard( # --- verify -------------------------------------------------------------- if doctor_fn is not None and status == "completed": health = doctor_fn(scope=scope) - hstatus = (health.get("status") or health.get("health") or {}).get("status", "unknown") + raw_health = health.get("status") or health.get("health") or "unknown" + hstatus = raw_health + if isinstance(raw_health, dict): + hstatus = raw_health.get("status", "unknown") color = "32" if hstatus in ("healthy", "PASS") else "33" print(f" {i18n.t('health', ctx.language)}: {_c(ctx, color, str(hstatus))}")