Skip to content

Commit 6663f0d

Browse files
shuvebclaude
andcommitted
Move to global ~/.mfbt config, integrate Ralph into TUI, add auto re-auth
Major changes: - Migrate config from per-project .mfbt/ to global ~/.mfbt/ (schema v1→v2) - Add shared OAuth flow (auth_flow.py) with auto token refresh and interactive re-auth on 401 via on_auth_required callback - Integrate Ralph orchestration into main TUI via RalphPanel widget (r key), replacing standalone tui_app.py - Add coding agent pre-flight checks (coding_agents.py) with PreflightModal for verifying agent installation and MCP config - Extend TUI to 4-level navigation: Projects > Phases > Modules > Features - Simplify config/auth functions to operate on ~/.mfbt/ without requiring project_root parameter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5f83cb7 commit 6663f0d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+4619
-2202
lines changed

CLAUDE.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- **Language:** Python 3.10+ (uses modern features: pattern matching, improved type hints)
88
- **Backend:** Integrates with mfbt REST API (`{BASE_URL}/api/v1/`)
99
- **Auth:** OAuth 2.1 with PKCE (1hr access tokens, 30-day refresh tokens), plus API key management (`mfbtsk-{uuid}`, passed as `Authorization: Bearer {key}`)
10-
- **Config:** `.mfbt` directory stores auth tokens and project configuration
10+
- **Config:** Global `~/.mfbt/` directory stores auth tokens and configuration (no per-project config)
1111

1212
## API Specification
1313

@@ -17,11 +17,12 @@ The file `openapi.json` in the project root contains the full OpenAPI spec for t
1717

1818
### Core Systems
1919

20-
1. **Authentication & Configuration** — Browser-based OAuth flow, token refresh/session management, API key support, project selection post-auth
21-
2. **Interactive TUI Mode** — Launched when CLI runs without subcommands; K9S-style keyboard-driven navigation with real-time status updates
22-
3. **Subcommands**`status`, `next`, `modules`, `features`, plus commands for brainstorming phases, implementations, and jobs. Consistent output formatting (table, JSON, etc.)
23-
4. **API Integration Layer** — REST client for all mfbt endpoints, paginated responses (`{items, total, page, page_size, total_pages}`), error handling (404 for unauthorized, 402 for token limits), job polling for async operations, WebSocket support for real-time job status
20+
1. **Authentication & Configuration** — Browser-based OAuth flow with PKCE, auto token refresh + interactive re-auth on 401, API key support. Shared auth flow in `auth_flow.py`. Config/auth in `~/.mfbt/` (schema v2, no per-project `project_id`).
21+
2. **Interactive TUI Mode** — Launched when CLI runs without subcommands; K9S-style keyboard-driven navigation: Projects > Phases > Modules > Features. Ralph orchestration integrated via `r` key.
22+
3. **Subcommands**`auth`, `projects` (list, show, create, archive, delete), `ralph`, `tui`. Consistent output formatting (table, JSON, etc.)
23+
4. **API Integration Layer** — REST client with `on_auth_required` callback for auto re-auth on 401. Paginated responses (`{items, total, page, page_size, total_pages}`), error handling (402 for token limits), job polling, WebSocket support.
2424
5. **API Key Management** — CRUD via `/api/v1/users/me/api-keys`
25+
6. **Coding Agent Checks**`coding_agents.py` provides pre-flight checks (agent installed, MCP configured) before Ralph orchestration.
2526

2627
### Key API Endpoints
2728

@@ -97,8 +98,12 @@ This project uses the **mfbt MCP server**, which exposes a virtual filesystem (V
9798
## Development
9899

99100
- **Venv:** `.venv/bin/python`, `.venv/bin/pytest`
100-
- **Run tests:** `.venv/bin/pytest tests/unit/ralph/ -v`
101+
- **Run tests:** `.venv/bin/pytest tests/unit/ -v`
102+
- **Config functions no longer take `project_root`**`load_config()`, `save_config()`, `load_auth()`, `save_auth()`, `init_mfbt_dir()`, `resolve_config()` all operate on `~/.mfbt/` via `get_mfbt_dir()`. `TokenManager.__init__` takes only `base_url`.
101103
- **API response formats:** List endpoints (`/features`, `/implementations`) return **paginated** `{"items": [...], "total": N, ...}` — always extract via `body["items"]`. Some endpoints (`/brainstorming-phases`) return plain lists. See `src/mfbt/tui/data_provider.py` for the canonical parsing pattern.
102-
- **Ralph subcommand:** `src/mfbt/commands/ralph/` — orchestrator, display (console), tui_display (Textual), agent runner, progress (API), prompt builder, types
103-
- **Ralph TUI:** Standalone Textual app (`tui_app.py`), separate from main mfbt TUI. Uses `RalphTUIDisplay` adapter (same 8-method interface as `RalphDisplay`) with `call_from_thread()` to marshal UI updates from the orchestrator's worker thread.
104+
- **TUI navigation:** Projects > Phases > Modules > Features (4 levels). `NavigationState` stack depth: 0=projects, 1=phases, 2=modules, 3=features. Phase list shows brainstorming phases + virtual "Orphan modules" entry.
105+
- **Ralph in TUI:** Integrated into main TUI (`r` key), not a standalone app. Uses `RalphPanel` widget in `#main-content` with `PreflightModal` for agent checks. Standalone `tui_app.py` deleted.
106+
- **Ralph subcommand:** `src/mfbt/commands/ralph/` — orchestrator, display (console), tui_display (Textual adapter), ralph_widgets (TUI widgets), agent runner, progress (API), prompt builder, types.
104107
- **Display duck typing:** `RalphOrchestrator.display` is typed as `Any` — both `RalphDisplay` and `RalphTUIDisplay` are structurally compatible (same 8 methods).
108+
- **Key new files:** `auth_flow.py` (shared OAuth), `coding_agents.py` (agent pre-flight checks), `tui/screens/phase_list.py`, `tui/screens/preflight_modal.py`, `tui/screens/ralph_panel.py`, `commands/ralph/ralph_widgets.py`.
109+
- **TUI shortcuts:** `r` = Ralph, `ctrl+r` = Refresh, `d` = Describe, `enter` = Open/Detail, `esc` = Back, `?` = Help, `q` = Quit.

src/mfbt/__main__.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def build_parser() -> argparse.ArgumentParser:
2222
commands:
2323
(none) Launch interactive TUI (default)
2424
auth Authentication commands (login, status, refresh, logout)
25-
projects Manage projects (list, show, create, switch, archive, delete)
25+
projects Manage projects (list, show, create, archive, delete)
2626
ralph Auto-invoke coding agents to implement pending features
2727
tui Launch interactive TUI
2828
@@ -78,23 +78,24 @@ def main(args: list[str] | None = None) -> None:
7878

7979
setup_logging(verbosity=parsed.verbose)
8080

81-
from mfbt.config import find_project_root, init_mfbt_dir, resolve_config
81+
from mfbt.config import init_mfbt_dir, is_authenticated, resolve_config
8282

8383
console = Console()
84-
project_root = find_project_root()
85-
if project_root is None:
86-
console.print("[red]Error:[/red] No project root found.")
84+
85+
init_mfbt_dir()
86+
87+
if not is_authenticated():
88+
console.print("[red]Error:[/red] Not authenticated.")
8789
console.print(
88-
"Run from within a project directory, or use [bold]mfbt --help[/bold]."
90+
"Run [bold]mfbt auth login[/bold] to authenticate first."
8991
)
9092
return
9193

92-
init_mfbt_dir(project_root)
93-
config = resolve_config(project_root)
94+
config = resolve_config()
9495

9596
from mfbt.tui import launch_tui
9697

97-
launch_tui(project_root, config)
98+
launch_tui(config)
9899

99100

100101
if __name__ == "__main__":

src/mfbt/api_client.py

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import random
1313
import threading
1414
import time
15-
from collections.abc import Iterator
15+
from collections.abc import Callable, Iterator
1616
from dataclasses import dataclass
1717
from typing import TYPE_CHECKING, Any
1818

@@ -272,11 +272,13 @@ def __init__(
272272
timeout: float = DEFAULT_TIMEOUT,
273273
max_retries: int = DEFAULT_MAX_RETRIES,
274274
cache: ResponseCache | None = None,
275+
on_auth_required: Callable[[], bool] | None = None,
275276
) -> None:
276277
self.base_url = base_url.rstrip("/")
277278
self.token_manager = token_manager
278279
self.max_retries = max_retries
279280
self.cache = cache
281+
self.on_auth_required = on_auth_required
280282
self._circuit_breaker = CircuitBreaker()
281283
self._client = httpx.Client(
282284
timeout=timeout,
@@ -418,6 +420,27 @@ def _request(
418420

419421
return api_response
420422

423+
def _obtain_auth_headers(
424+
self, headers: dict[str, str] | None
425+
) -> dict[str, str]:
426+
"""Get merged auth + caller headers, with auto-login fallback.
427+
428+
If getting the access token raises a TokenError (e.g. refresh
429+
token expired), invoke the ``on_auth_required`` callback to let
430+
the user re-authenticate interactively, then retry once.
431+
"""
432+
from mfbt.token_manager import TokenError
433+
434+
try:
435+
auth_headers = self._get_auth_headers()
436+
except TokenError:
437+
if self.on_auth_required is None or not self.on_auth_required():
438+
raise
439+
# Callback succeeded — retry getting headers
440+
auth_headers = self._get_auth_headers()
441+
442+
return {**auth_headers, **(headers or {})}
443+
421444
def _execute_with_retry(
422445
self,
423446
method: str,
@@ -430,12 +453,11 @@ def _execute_with_retry(
430453
"""Execute an HTTP request with retry logic and circuit breaker."""
431454
self._circuit_breaker.check_state()
432455

456+
merged_headers = self._obtain_auth_headers(headers)
457+
433458
last_error: Exception | None = None
434459

435460
for attempt in range(self.max_retries):
436-
auth_headers = self._get_auth_headers()
437-
merged_headers = {**auth_headers, **(headers or {})}
438-
439461
try:
440462
response = self._client.request(
441463
method,
@@ -461,6 +483,36 @@ def _execute_with_retry(
461483
technical_details=str(exc),
462484
) from exc
463485

486+
# Handle 401 — try token refresh, then interactive auth
487+
if response.status_code == 401:
488+
logger.info("Received 401, attempting token refresh")
489+
merged_headers = self._handle_auth_failure(headers)
490+
if merged_headers is not None:
491+
# Retry the request once with fresh headers
492+
try:
493+
response = self._client.request(
494+
method,
495+
url,
496+
params=params,
497+
json=json_body,
498+
headers=merged_headers,
499+
)
500+
except Exception as exc:
501+
self._circuit_breaker.record_failure()
502+
raise NetworkError(
503+
technical_details=str(exc),
504+
) from exc
505+
506+
if response.status_code != 401:
507+
self._circuit_breaker.record_success()
508+
return response
509+
510+
# Still 401 after recovery attempts — fall through to
511+
# normal status handling (will raise AuthenticationRequired
512+
# in _raise_for_status)
513+
self._circuit_breaker.record_success()
514+
return response
515+
464516
# Check for retryable HTTP status
465517
if _is_retryable_status(response.status_code):
466518
last_error = APIError(
@@ -496,6 +548,35 @@ def _execute_with_retry(
496548
original=last_error,
497549
) from last_error
498550

551+
def _handle_auth_failure(
552+
self, original_headers: dict[str, str] | None
553+
) -> dict[str, str] | None:
554+
"""Attempt to recover from an authentication failure.
555+
556+
1. Try refreshing the token via ``token_manager.refresh_tokens()``.
557+
2. If refresh fails and ``on_auth_required`` is set, invoke it
558+
to trigger interactive re-authentication.
559+
560+
Returns merged headers on success, or None if recovery failed.
561+
"""
562+
from mfbt.token_manager import TokenError
563+
564+
# First try: refresh the token
565+
try:
566+
self.token_manager.refresh_tokens()
567+
return {**self._get_auth_headers(), **(original_headers or {})}
568+
except TokenError:
569+
logger.info("Token refresh failed, trying interactive auth")
570+
571+
# Second try: interactive re-auth
572+
if self.on_auth_required is not None and self.on_auth_required():
573+
try:
574+
return {**self._get_auth_headers(), **(original_headers or {})}
575+
except TokenError:
576+
logger.warning("Auth headers still invalid after interactive login")
577+
578+
return None
579+
499580
def _raise_for_status(self, response: httpx.Response) -> None:
500581
"""Map HTTP error status codes to specific exceptions."""
501582
status = response.status_code

src/mfbt/auth.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -572,13 +572,8 @@ def exchange_code_for_tokens(
572572
)
573573

574574

575-
def store_tokens(project_root: Path, token_response: TokenResponse) -> None:
576-
"""Store OAuth tokens using the existing config system.
577-
578-
Args:
579-
project_root: The project root containing .mfbt/.
580-
token_response: The token response to store.
581-
"""
575+
def store_tokens(token_response: TokenResponse) -> None:
576+
"""Store OAuth tokens using the existing config system."""
582577
from mfbt.config import save_auth
583578

584579
auth_data = {
@@ -588,5 +583,5 @@ def store_tokens(project_root: Path, token_response: TokenResponse) -> None:
588583
"expires_at": token_response.expires_at,
589584
"issued_at": token_response.issued_at,
590585
}
591-
save_auth(project_root, auth_data)
592-
logger.info("Tokens stored in .mfbt/auth.json")
586+
save_auth(auth_data)
587+
logger.info("Tokens stored in ~/.mfbt/auth.json")

src/mfbt/auth_flow.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Shared browser-based OAuth login flow.
2+
3+
Extracted from tui/__init__.py so both TUI and CLI commands can
4+
trigger re-authentication when token refresh fails.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
from typing import Any
11+
12+
logger = logging.getLogger("mfbt.auth_flow")
13+
14+
15+
def run_interactive_auth(
16+
base_url: str,
17+
token_manager: Any,
18+
console: Any,
19+
) -> bool:
20+
"""Run the browser-based OAuth login flow.
21+
22+
Opens the user's browser for OAuth authentication with PKCE,
23+
waits for the callback, and saves the resulting tokens.
24+
25+
Args:
26+
base_url: The mfbt API base URL.
27+
token_manager: A TokenManager instance for saving tokens.
28+
console: A Rich Console instance for user feedback.
29+
30+
Returns:
31+
True if authentication succeeded, False otherwise.
32+
"""
33+
import webbrowser
34+
from urllib.parse import urlencode
35+
36+
from mfbt.auth import (
37+
OAUTH_SCOPES,
38+
AuthError,
39+
AuthTimeoutError,
40+
ClientRegistrationError,
41+
TokenExchangeError,
42+
exchange_code_for_tokens,
43+
generate_pkce_params,
44+
start_callback_server,
45+
wait_for_callback,
46+
)
47+
48+
pkce = generate_pkce_params()
49+
50+
try:
51+
server, port = start_callback_server(pkce.state)
52+
except AuthError as exc:
53+
console.print(f"[red]Error:[/red] {exc}")
54+
return False
55+
56+
redirect_uri = f"http://127.0.0.1:{port}/callback"
57+
58+
# Register OAuth client (RFC 7591 Dynamic Client Registration)
59+
console.print("Registering OAuth client...")
60+
try:
61+
client_id = token_manager.register_and_save_client([redirect_uri])
62+
except ClientRegistrationError as exc:
63+
server.server_close()
64+
console.print(f"[red]Error:[/red] {exc}")
65+
return False
66+
67+
auth_params = {
68+
"client_id": client_id,
69+
"redirect_uri": redirect_uri,
70+
"code_challenge": pkce.code_challenge,
71+
"code_challenge_method": pkce.code_challenge_method,
72+
"response_type": "code",
73+
"state": pkce.state,
74+
"scope": OAUTH_SCOPES,
75+
}
76+
auth_url = f"{base_url.rstrip('/')}/oauth/authorize?{urlencode(auth_params)}"
77+
78+
console.print("Opening browser for authentication...")
79+
webbrowser.open(auth_url)
80+
console.print(f"Waiting for callback (timeout: 120s)...\n")
81+
82+
try:
83+
result = wait_for_callback(server, timeout=120)
84+
except AuthTimeoutError:
85+
console.print("[red]Error:[/red] Authentication timed out. Please try again.")
86+
return False
87+
except AuthError as exc:
88+
console.print(f"[red]Error:[/red] {exc}")
89+
return False
90+
except KeyboardInterrupt:
91+
console.print("\nAuthentication cancelled.")
92+
return False
93+
finally:
94+
server.server_close()
95+
96+
console.print("Exchanging authorization code for tokens...")
97+
try:
98+
token_response = exchange_code_for_tokens(
99+
base_url=base_url,
100+
code=result.code,
101+
code_verifier=pkce.code_verifier,
102+
redirect_uri=redirect_uri,
103+
client_id=client_id,
104+
)
105+
except TokenExchangeError as exc:
106+
console.print(f"[red]Error:[/red] {exc}")
107+
return False
108+
109+
token_manager.save_tokens(token_response)
110+
console.print("[green]Successfully authenticated![/green]\n")
111+
return True

0 commit comments

Comments
 (0)