fix: single-key TTY menu UX for the Gitea config menu (#36)

Make the interactive profile menu feel like a real terminal menu, via a new
injectable MenuIO abstraction (no menu logic change, no auth/secret-storage
change).

- Single-key top-level actions in a TTY (termios/tty raw read); no Enter
  needed. Non-TTY / test runs fall back to line input.
- Enter backs out: Enter (or 0) on the main menu quits; Enter cancels any
  submenu/profile prompt and returns.
- Profile chooser: everywhere a profile is needed, show a numbered list and
  pick by key (1-9), with an explicit 'm) type a name manually' path and Enter
  to cancel. Empty config handled gracefully.
- Clear screen before redrawing the main menu and chooser — TTY only; never
  emits clear codes in non-TTY/test runs.
- Result actions (validate/test-auth/whoami/eligibility) print a concise result
  then pause for a keypress in a TTY; non-TTY never blocks.

Helpers: read_key (via default_io) / choose_menu_option / choose_profile /
clear_screen / pause_for_key, plus MenuIO(is_tty, clear_enabled). TTY detected
with sys.stdin.isatty() and sys.stdout.isatty(); stdlib only.

Safety unchanged: no tokens/passwords printed, no raw config dumps, no
.env.personal, no change to auth behavior or secret storage.

Tests: rewrote menu tests around a scripted _FakeIO (no real terminal): single-
key select + clear, main-menu Enter/0 quit, submenu Enter cancel (no change),
chooser lists/selects/no-profiles/manual/out-of-range, non-TTY line fallback,
clear-only-when-enabled, pause never hangs non-TTY, and add-flow proving the
token value never reaches disk or stdout.

Docs: runbook note on single-key nav / Enter back-out / numbered chooser.
scripts/gitea-config-menu unchanged.

Closes #36. Refs #31, #34.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 02:34:16 -04:00
parent 5272e071e1
commit 69d4edf37d
3 changed files with 439 additions and 119 deletions
+265 -78
View File
@@ -1,15 +1,17 @@
"""Interactive setup menu for the canonical Gitea MCP profile config (#31).
"""Interactive setup menu for the canonical Gitea MCP profile config (#31, #36).
Create / edit / remove / validate profiles, test a profile's authentication,
show the authenticated user, check reviewer eligibility for a PR, and print
thin launcher snippets for Claude / Gemini / Codex — without hand-editing JSON
and without ever putting a raw token in a launcher config.
Run: ``python gitea_config.py menu`` (or ``python gitea_config_menu.py``)
Run: ``./scripts/gitea-config-menu`` (or ``python gitea_config.py menu``).
Every side effect (stdin, secret prompt, stdout, keychain store, token
resolution, HTTP) is injectable so the menu logic is testable without a real
keychain, terminal, or network.
UX (#36): in an interactive TTY the top-level menu and profile chooser take a
single keypress (no Enter); Enter quits the main menu and cancels/back-outs of
any submenu; the screen clears before redraws; results pause for a keypress.
All of that runs through an injectable :class:`MenuIO`, so tests exercise the
logic with scripted keys and no real terminal, keychain, or network.
"""
import sys
import json
@@ -100,135 +102,320 @@ def launcher_snippets(profile_name, config_path):
return {"claude": claude, "gemini": gemini, "codex": codex}
# ── Interactive prompts (thin wrappers over injected IO) ────────────────────────
# ── Injectable menu IO ──────────────────────────────────────────────────────────
def _prompt_profile(input_fn, secret_fn, out, keychain_set):
"""Collect a new/edited profile from the user. Returns (name, profile).
CLEAR_SEQUENCE = "\x1b[2J\x1b[H"
Tokens are read with the secret prompt (no echo) and stored in the keychain;
they are never placed in the returned profile.
class MenuIO:
"""Terminal IO for the menu, fully injectable for tests.
- ``read_key(prompt)`` → one keypress in a TTY, or the first character of a
line otherwise. Enter / EOF return ``""`` (used as cancel/back/quit).
- ``read_line(prompt)`` → a full line (for free text). EOF returns ``""``.
- ``out(text)`` → write a line.
- ``clear()`` → clear the screen, but only when ``clear_enabled``.
``is_tty`` gates single-key input and the pause prompt; ``clear_enabled``
gates screen clearing so automated/non-TTY runs never emit control codes.
"""
name = input_fn("Profile name (e.g. prgs-reviewer): ").strip()
if not gitea_config.is_valid_profile_name(name):
raise gitea_config.ConfigError(f"invalid profile name {name!r}")
base_url = input_fn("Base URL (e.g. https://gitea.prgs.cc): ").strip()
username = input_fn("Gitea username: ").strip()
default_owner = input_fn("Default owner (optional): ").strip()
default_repo = input_fn("Default repo (optional): ").strip()
execution_profile = input_fn("Execution profile (optional): ").strip()
auth_type = input_fn("Auth type [keychain/env]: ").strip().lower()
def __init__(self, *, read_key, read_line, write, is_tty=False,
clear_enabled=False, clear_fn=None):
self._read_key = read_key
self._read_line = read_line
self._write = write
self.is_tty = is_tty
self.clear_enabled = clear_enabled
self._clear_fn = clear_fn
def read_key(self, prompt=""):
return self._read_key(prompt)
def read_line(self, prompt=""):
return self._read_line(prompt)
def out(self, text=""):
self._write(text)
def clear(self):
if self.clear_enabled and self._clear_fn:
self._clear_fn()
def _read_key_tty():
"""Read a single keypress from a real TTY without requiring Enter.
Returns ``""`` for Enter, raises KeyboardInterrupt on Ctrl-C. macOS/Linux
only (termios/tty); callers use a line-input fallback when not a TTY.
"""
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
if ch in ("\r", "\n", ""):
return ""
if ch == "\x03":
raise KeyboardInterrupt
return ch
def default_io():
"""Build the real MenuIO: single-key in a TTY, line-input otherwise."""
is_tty = bool(getattr(sys.stdin, "isatty", lambda: False)()) and \
bool(getattr(sys.stdout, "isatty", lambda: False)())
def write(text=""):
print(text)
def read_key(prompt=""):
if prompt:
sys.stdout.write(prompt)
sys.stdout.flush()
if is_tty:
key = _read_key_tty()
print() # move off the prompt line
return key
line = sys.stdin.readline()
if line == "":
return "" # EOF → back/quit
return line.rstrip("\r\n")[:1]
def read_line(prompt=""):
try:
return input(prompt)
except EOFError:
return ""
def clear():
sys.stdout.write(CLEAR_SEQUENCE)
sys.stdout.flush()
return MenuIO(read_key=read_key, read_line=read_line, write=write,
is_tty=is_tty, clear_enabled=is_tty, clear_fn=clear)
# ── UX helpers ──────────────────────────────────────────────────────────────────
_MENU_LINES = [
"Gitea MCP profile setup",
" 1) list profiles",
" 2) add profile",
" 3) edit profile",
" 4) remove profile",
" 5) validate config",
" 6) test profile authentication",
" 7) show authenticated user",
" 8) generate launcher snippets (Claude/Gemini/Codex)",
" 9) check reviewer eligibility for a PR",
" (Enter or 0) quit",
]
def clear_screen(io):
"""Clear the terminal (no-op unless the IO has clearing enabled)."""
io.clear()
def pause_for_key(io):
"""Wait for a keypress in a TTY; return immediately otherwise (never hangs)."""
if io.is_tty:
io.out("Press any key to continue")
io.read_key()
def choose_menu_option(io):
"""Clear, draw the main menu, and return the pressed key ('' = quit)."""
clear_screen(io)
for line in _MENU_LINES:
io.out(line)
return io.read_key("choice: ")
def choose_profile(io, config, purpose="profile"):
"""Show a numbered profile list and return the chosen name, or None.
Enter cancels (returns None). Digits 1-9 select from the list. 'm' opens an
explicit manual-entry path. Empty config is handled gracefully.
"""
names = sorted((config.get("profiles") or {}))
clear_screen(io)
if not names:
io.out("(no profiles yet — add one first)")
pause_for_key(io)
return None
io.out(f"Select {purpose} (Enter to cancel):")
for i, name in enumerate(names[:9], start=1):
io.out(f" {i}) {name}")
io.out(" m) type a name manually")
key = io.read_key("choose: ")
if key == "":
return None
if key == "m":
typed = io.read_line("profile name: ").strip()
return typed or None
if key.isdigit():
idx = int(key)
if 1 <= idx <= min(len(names), 9):
return names[idx - 1]
io.out("invalid selection")
pause_for_key(io)
return None
def _prompt_profile_fields(io, secret_fn, keychain_set, name):
"""Prompt for a profile's fields for *name*. Returns a profile dict or None.
A blank Base URL cancels. Tokens are read with the secret prompt (no echo)
and stored in the keychain; they are never placed in the returned profile.
"""
base_url = io.read_line("Base URL (e.g. https://gitea.prgs.cc; Enter cancels): ").strip()
if not base_url:
return None
username = io.read_line("Gitea username: ").strip()
default_owner = io.read_line("Default owner (optional): ").strip()
default_repo = io.read_line("Default repo (optional): ").strip()
execution_profile = io.read_line("Execution profile (optional): ").strip()
auth_type = io.read_line("Auth type [keychain/env]: ").strip().lower()
if auth_type == "keychain":
default_id = f"{name}-gitea-token"
item_id = input_fn(f"Keychain item id [{default_id}]: ").strip() or default_id
store = input_fn("Store a token in the keychain now? [y/N]: ").strip().lower()
item_id = io.read_line(f"Keychain item id [{default_id}]: ").strip() or default_id
store = io.read_line("Store a token in the keychain now? [y/N]: ").strip().lower()
if store == "y":
token = secret_fn("Gitea token (hidden): ")
keychain_set(item_id, token) # token never echoed
out(f"Stored token in keychain item '{item_id}'.")
io.out(f"Stored token in keychain item '{item_id}'.")
auth = gitea_config.keychain_auth(item_id)
elif auth_type == "env":
default_var = f"GITEA_TOKEN_{name.upper().replace('-', '_').replace('.', '_')}"
var = input_fn(f"Env var name [{default_var}]: ").strip() or default_var
out(f"Set {var} in the environment; its value is never stored here.")
var = io.read_line(f"Env var name [{default_var}]: ").strip() or default_var
io.out(f"Set {var} in the environment; its value is never stored here.")
auth = gitea_config.env_auth(var)
else:
raise gitea_config.ConfigError("auth type must be 'keychain' or 'env'")
profile = gitea_config.build_profile(
return gitea_config.build_profile(
base_url=base_url, auth=auth, username=username,
default_owner=default_owner, default_repo=default_repo,
execution_profile=execution_profile,
)
return name, profile
_MENU = """
Gitea MCP profile setup
1) list profiles
2) add profile
3) edit profile
4) remove profile
5) validate config
6) test profile authentication
7) show authenticated user
8) generate launcher snippets (Claude/Gemini/Codex)
9) check reviewer eligibility for a PR
0) quit
"""
def main(argv=None, *, input_fn=input, secret_fn=None, out=None, config_path=None,
def main(argv=None, *, io=None, secret_fn=None, config_path=None,
resolve_token=gitea_config.resolve_token,
api_request=gitea_auth.api_request,
keychain_set=gitea_config.keychain_set):
"""Run the interactive menu loop. Returns a process exit code."""
io = io or default_io()
secret_fn = secret_fn or getpass.getpass
out = out or (lambda *a: print(*a))
path = config_path or gitea_config.config_path() or gitea_config.DEFAULT_CONFIG_PATH
try:
config = gitea_config.load_config(path) or gitea_config.empty_config()
except gitea_config.ConfigError as exc:
out(f"config error: {exc}")
io.out(f"config error: {exc}")
config = gitea_config.empty_config()
while True:
out(_MENU)
choice = input_fn("choice: ").strip()
key = choose_menu_option(io)
if key in ("", "0"): # Enter or 0 → quit
io.out("bye")
return 0
try:
if choice == "0":
out("bye")
return 0
elif choice == "1":
if key == "1":
names = sorted(config.get("profiles") or {})
out("profiles: " + (", ".join(names) if names else "(none)"))
elif choice == "2":
name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set)
clear_screen(io)
io.out("profiles: " + (", ".join(names) if names else "(none)"))
pause_for_key(io)
elif key == "2":
name = io.read_line("New profile name (Enter to cancel): ").strip()
if not name:
continue
if not gitea_config.is_valid_profile_name(name):
io.out(f"invalid profile name {name!r}")
pause_for_key(io)
continue
profile = _prompt_profile_fields(io, secret_fn, keychain_set, name)
if profile is None:
continue
config = gitea_config.add_profile(config, name, profile)
saved = gitea_config.save_config(config, path)
out(f"added '{name}'; wrote {saved}")
elif choice == "3":
name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set)
io.out(f"added '{name}'; wrote {saved}")
pause_for_key(io)
elif key == "3":
name = choose_profile(io, config, "profile to edit")
if not name:
continue
profile = _prompt_profile_fields(io, secret_fn, keychain_set, name)
if profile is None:
continue
config = gitea_config.upsert_profile(config, name, profile)
saved = gitea_config.save_config(config, path)
out(f"saved '{name}'; wrote {saved}")
elif choice == "4":
name = input_fn("profile to remove: ").strip()
io.out(f"saved '{name}'; wrote {saved}")
pause_for_key(io)
elif key == "4":
name = choose_profile(io, config, "profile to remove")
if not name:
continue
config = gitea_config.remove_profile(config, name)
saved = gitea_config.save_config(config, path)
out(f"removed '{name}'; wrote {saved}")
elif choice == "5":
io.out(f"removed '{name}'; wrote {saved}")
pause_for_key(io)
elif key == "5":
problems = gitea_config.validate_config(config)
out("valid" if not problems else "problems:\n " + "\n ".join(problems))
elif choice in ("6", "7"):
name = input_fn("profile: ").strip()
clear_screen(io)
io.out("valid" if not problems
else "problems:\n " + "\n ".join(problems))
pause_for_key(io)
elif key in ("6", "7"):
name = choose_profile(io, config, "profile")
if not name:
continue
profile = gitea_config.select_profile(config, name)
who = resolve_identity(profile, resolve_token=resolve_token,
api_request=api_request)
out(f"authenticated as: {who}")
elif choice == "8":
name = input_fn("profile: ").strip()
io.out(f"authenticated as: {who}")
pause_for_key(io)
elif key == "8":
name = choose_profile(io, config, "profile")
if not name:
continue
clear_screen(io)
for client, snippet in launcher_snippets(name, path).items():
out(f"--- {client} ---\n{snippet}")
elif choice == "9":
name = input_fn("profile: ").strip()
pr_number = input_fn("PR number: ").strip()
io.out(f"--- {client} ---\n{snippet}")
pause_for_key(io)
elif key == "9":
name = choose_profile(io, config, "profile")
if not name:
continue
pr_number = io.read_line("PR number (Enter to cancel): ").strip()
if not pr_number:
continue
profile = gitea_config.select_profile(config, name)
result = check_eligibility(
profile, pr_number, resolve_token=resolve_token,
api_request=api_request)
out(f"authenticated user: {result['authenticated_user']}")
out(f"PR author: {result['pr_author']}")
out("ELIGIBLE" if result["eligible"] else "INELIGIBLE")
io.out(f"authenticated user: {result['authenticated_user']}")
io.out(f"PR author: {result['pr_author']}")
io.out("ELIGIBLE" if result["eligible"] else "INELIGIBLE")
for r in result["reasons"]:
out(f" - {r}")
io.out(f" - {r}")
pause_for_key(io)
else:
out("unknown choice")
io.out("unknown choice")
pause_for_key(io)
except gitea_config.ConfigError as exc:
out(f"error: {exc}")
io.out(f"error: {exc}")
pause_for_key(io)
except Exception as exc: # noqa: BLE001 - keep the menu alive, no secrets
out(f"error: {type(exc).__name__}")
io.out(f"error: {type(exc).__name__}")
pause_for_key(io)
if __name__ == "__main__": # pragma: no cover