Skip to content

Commit d7fc661

Browse files
shuvebclaude
andcommitted
Add special instructions, auth modals, safer OAuth client registration, bump to 0.1.9
- Defer saving OAuth client_id until auth flow fully succeeds (prevents invalidating existing refresh tokens on failed re-auth) - Add TUI auth-required modal with in-app re-authentication support - Add special instructions modal for Ralph (per-project agent instructions, `i` key) - Add --instructions flag to `mfbt ralph` CLI command - Show project name in Ralph header - Skip orchestration when all features are already completed - Improve token expiry status messages (distinguish expired access vs refresh tokens) - Remove auto-browser-open on 401 in CLI subcommands (just print re-auth instructions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 26971c2 commit d7fc661

22 files changed

+515
-36
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "mfbt-cli"
7-
version = "0.1.8"
7+
version = "0.1.9"
88
description = "CLI tool for the mfbt platform — interactive TUI and subcommands for managing mfbt projects"
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/mfbt/auth_flow.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def run_interactive_auth(
4141
TokenExchangeError,
4242
exchange_code_for_tokens,
4343
generate_pkce_params,
44+
register_client,
4445
start_callback_server,
4546
wait_for_callback,
4647
)
@@ -55,10 +56,14 @@ def run_interactive_auth(
5556

5657
redirect_uri = f"http://127.0.0.1:{port}/callback"
5758

58-
# Register OAuth client (RFC 7591 Dynamic Client Registration)
59+
# Register OAuth client (RFC 7591 Dynamic Client Registration).
60+
# Important: do NOT save the client_id yet — if the browser auth flow
61+
# fails or times out, we must not overwrite the existing client_id
62+
# (which the current refresh token is bound to).
5963
console.print("Registering OAuth client...")
6064
try:
61-
client_id = token_manager.register_and_save_client([redirect_uri])
65+
registered = register_client(base_url, [redirect_uri])
66+
client_id = registered.client_id
6267
except ClientRegistrationError as exc:
6368
server.server_close()
6469
console.print(f"[red]Error:[/red] {exc}")
@@ -106,6 +111,8 @@ def run_interactive_auth(
106111
console.print(f"[red]Error:[/red] {exc}")
107112
return False
108113

114+
# Only save the client_id now that the full auth flow succeeded.
115+
token_manager.save_client_id(client_id)
109116
token_manager.save_tokens(token_response)
110117
console.print("[green]Successfully authenticated![/green]\n")
111118
return True

src/mfbt/cli.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,16 @@ def login(
157157

158158
redirect_uri = f"http://127.0.0.1:{port}/callback"
159159

160-
# Register OAuth client (RFC 7591 Dynamic Client Registration)
160+
# Register OAuth client (RFC 7591 Dynamic Client Registration).
161+
# Important: do NOT save the client_id yet — if the auth flow fails,
162+
# we must not overwrite the existing client_id that the current
163+
# refresh token is bound to.
161164
console.print("Registering OAuth client...")
162165
try:
163-
client_id = tm.register_and_save_client([redirect_uri])
166+
from mfbt.auth import register_client
167+
168+
registered = register_client(effective_base_url, [redirect_uri])
169+
client_id = registered.client_id
164170
except ClientRegistrationError as exc:
165171
server.server_close()
166172
console.print(f"[red]Error:[/red] {exc}")
@@ -222,7 +228,8 @@ def login(
222228
console.print(f"[red]Error:[/red] {exc}")
223229
raise typer.Exit(code=1) from exc
224230

225-
# Store tokens
231+
# Only save client_id now that the full auth flow succeeded.
232+
tm.save_client_id(client_id)
226233
tm.save_tokens(token_response)
227234
console.print("[green]Successfully authenticated![/green]")
228235

@@ -269,11 +276,19 @@ def status() -> None:
269276
expiry = datetime.fromisoformat(expires_at)
270277
now = datetime.now(timezone.utc)
271278
if now >= expiry:
272-
console.print("[red]Token expired.[/red]")
273-
console.print(
274-
"Run [bold]mfbt auth login[/bold] or "
275-
"[bold]mfbt auth refresh[/bold]."
276-
)
279+
if tokens.get("refresh_token"):
280+
console.print(
281+
"[yellow]Access token expired[/yellow] "
282+
"(refresh token still valid — will auto-refresh on next command)."
283+
)
284+
console.print(
285+
"Or run [bold]mfbt auth refresh[/bold] to refresh now."
286+
)
287+
else:
288+
console.print("[red]Token expired (no refresh token).[/red]")
289+
console.print(
290+
"Run [bold]mfbt auth login[/bold] to re-authenticate."
291+
)
277292
else:
278293
remaining = expiry - now
279294
minutes = int(remaining.total_seconds() // 60)

src/mfbt/commands/projects.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
def _get_api_client(ctx: typer.Context) -> Any:
5959
"""Build an APIClient from ctx.obj."""
6060
from mfbt.api_client import APIClient
61-
from mfbt.auth_flow import run_interactive_auth
6261
from mfbt.cache import ResponseCache
6362
from mfbt.config import get_mfbt_dir
6463
from mfbt.token_manager import TokenManager
@@ -71,8 +70,11 @@ def _get_api_client(ctx: typer.Context) -> Any:
7170
cache = ResponseCache(cache_dir)
7271

7372
def _on_auth_required() -> bool:
74-
console.print("\n[yellow]Session expired. Re-authenticating...[/yellow]")
75-
return run_interactive_auth(base_url, tm, console)
73+
console.print(
74+
"\n[red]Authentication failed.[/red] "
75+
"Run [bold]mfbt auth login[/bold] to re-authenticate."
76+
)
77+
return False
7678

7779
return APIClient(base_url, tm, cache=cache, on_auth_required=_on_auth_required)
7880

src/mfbt/commands/ralph/__init__.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
def _get_api_client(ctx: typer.Context) -> Any:
2929
"""Build an APIClient from ctx.obj."""
3030
from mfbt.api_client import APIClient
31-
from mfbt.auth_flow import run_interactive_auth
3231
from mfbt.token_manager import TokenManager
3332

3433
config = ctx.obj["config"]
@@ -37,8 +36,11 @@ def _get_api_client(ctx: typer.Context) -> Any:
3736
tm = TokenManager(base_url)
3837

3938
def _on_auth_required() -> bool:
40-
console.print("\n[yellow]Session expired. Re-authenticating...[/yellow]")
41-
return run_interactive_auth(base_url, tm, console)
39+
console.print(
40+
"\n[red]Authentication failed.[/red] "
41+
"Run [bold]mfbt auth login[/bold] to re-authenticate."
42+
)
43+
return False
4244

4345
return APIClient(base_url, tm, cache=None, on_auth_required=_on_auth_required)
4446

@@ -123,6 +125,12 @@ def ralph(
123125
"-y",
124126
help="Skip confirmation prompt and start immediately",
125127
),
128+
instructions: Optional[str] = typer.Option(
129+
None,
130+
"--instructions",
131+
help="Special instructions for the coding agent (max 512 chars). "
132+
"If not provided, loads saved instructions for this project.",
133+
),
126134
status: bool = typer.Option(
127135
False,
128136
"--status",
@@ -154,6 +162,21 @@ def ralph(
154162
# Resolve project identifier to UUID
155163
project_id = _resolve_project_identifier(ctx, project)
156164

165+
# Resolve special instructions
166+
from mfbt.config import load_special_instructions, save_special_instructions
167+
168+
if instructions is not None:
169+
if len(instructions) > 512:
170+
console.print(
171+
"[red]Error:[/red] Instructions must be 512 characters or fewer "
172+
f"(got {len(instructions)})."
173+
)
174+
raise typer.Exit(code=1)
175+
save_special_instructions(project_id, instructions)
176+
resolved_instructions = instructions if instructions.strip() else None
177+
else:
178+
resolved_instructions = load_special_instructions(project_id)
179+
157180
# Validate coding agent
158181
if coding_agent not in SUPPORTED_AGENTS:
159182
console.print(
@@ -201,6 +224,7 @@ def ralph(
201224
max_turns=max_turns,
202225
quiet=quiet,
203226
base_url=base_url,
227+
special_instructions=resolved_instructions,
204228
)
205229

206230
# Fetch progress for each phase

src/mfbt/commands/ralph/agent.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,11 @@ def __init__(
119119
self,
120120
agent_type: AgentType,
121121
max_turns: int | None = None,
122+
special_instructions: str | None = None,
122123
) -> None:
123124
self._agent_type = agent_type
124125
self._max_turns = max_turns
126+
self._special_instructions = special_instructions
125127
self._process: subprocess.Popen | None = None
126128

127129
def build_command(self, prompt: str) -> list[str]:
@@ -136,7 +138,13 @@ def build_command(self, prompt: str) -> list[str]:
136138
]
137139
if self._max_turns is not None:
138140
cmd.extend(["--max-turns", str(self._max_turns)])
139-
cmd.extend(["--append-system-prompt", self._APPEND_SYSTEM_PROMPT])
141+
append_prompt = self._APPEND_SYSTEM_PROMPT
142+
if self._special_instructions:
143+
append_prompt += (
144+
"\n\nAdditional instructions from the project owner:\n"
145+
+ self._special_instructions
146+
)
147+
cmd.extend(["--append-system-prompt", append_prompt])
140148
return cmd
141149
msg = f"Unsupported agent type: {self._agent_type}"
142150
raise ValueError(msg)

src/mfbt/commands/ralph/orchestrator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def __init__(
5050
self._client = client
5151
self._display = display
5252
self._logger = logger
53-
self._agent = AgentRunner(config.coding_agent, config.max_turns)
53+
self._agent = AgentRunner(config.coding_agent, config.max_turns, config.special_instructions)
5454
self._results: list[FeatureResult] = []
5555
self._session_start: float = 0.0
5656
self._stop_event = threading.Event()

src/mfbt/commands/ralph/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class RalphConfig:
4646
max_turns: int | None
4747
quiet: bool
4848
base_url: str
49+
special_instructions: str | None = None
4950

5051
@property
5152
def phase_ids(self) -> list[str]:

src/mfbt/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,25 @@ def _load_json(path: Path, defaults: dict[str, Any]) -> dict[str, Any]:
442442
return dict(defaults)
443443

444444

445+
def load_special_instructions(project_id: str) -> str | None:
446+
"""Load special instructions for a project from ``~/.mfbt/special_instructions.json``."""
447+
path = get_mfbt_dir() / "special_instructions.json"
448+
data = _load_json(path, {})
449+
text = data.get(project_id)
450+
return text if isinstance(text, str) and text.strip() else None
451+
452+
453+
def save_special_instructions(project_id: str, text: str | None) -> None:
454+
"""Save special instructions for a project to ``~/.mfbt/special_instructions.json``."""
455+
path = get_mfbt_dir() / "special_instructions.json"
456+
data = _load_json(path, {})
457+
if text and text.strip():
458+
data[project_id] = text.strip()
459+
else:
460+
data.pop(project_id, None)
461+
_atomic_write_json(path, data)
462+
463+
445464
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
446465
"""Write JSON atomically via temp file + rename."""
447466
path.parent.mkdir(parents=True, exist_ok=True)

src/mfbt/tui/__init__.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,10 @@ def launch_tui(config: dict[str, Any]) -> None:
5151
return
5252

5353
def _on_auth_required() -> bool:
54-
from rich.console import Console
55-
56-
c = Console()
57-
c.print("\n[yellow]Session expired. Re-authenticating...[/yellow]")
58-
return run_interactive_auth(base_url, tm, c)
54+
# Don't auto-open the browser — just signal failure so the
55+
# API client surfaces an auth error. The TUI's token monitor
56+
# or notification will tell the user to run `mfbt auth login`.
57+
return False
5958

6059
mfbt_dir = get_mfbt_dir()
6160
cache = ResponseCache(mfbt_dir / "cache")

0 commit comments

Comments
 (0)