fluxloop-cli 0.3.2__py3-none-any.whl → 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. fluxloop_cli/__init__.py +1 -1
  2. fluxloop_cli/auth_manager.py +45 -1
  3. fluxloop_cli/commands/__init__.py +2 -6
  4. fluxloop_cli/commands/apikeys.py +61 -23
  5. fluxloop_cli/commands/auth.py +227 -37
  6. fluxloop_cli/commands/bundles.py +64 -11
  7. fluxloop_cli/commands/context.py +22 -2
  8. fluxloop_cli/commands/data.py +657 -0
  9. fluxloop_cli/commands/evaluate.py +165 -241
  10. fluxloop_cli/commands/init.py +159 -245
  11. fluxloop_cli/commands/inputs.py +178 -20
  12. fluxloop_cli/commands/local_context.py +69 -73
  13. fluxloop_cli/commands/personas.py +50 -56
  14. fluxloop_cli/commands/projects.py +39 -18
  15. fluxloop_cli/commands/scenarios.py +149 -13
  16. fluxloop_cli/commands/sync.py +185 -56
  17. fluxloop_cli/commands/test.py +136 -42
  18. fluxloop_cli/config_loader.py +12 -14
  19. fluxloop_cli/config_schema.py +3 -8
  20. fluxloop_cli/constants.py +31 -9
  21. fluxloop_cli/context_manager.py +445 -87
  22. fluxloop_cli/conversation_supervisor.py +63 -0
  23. fluxloop_cli/main.py +3 -7
  24. fluxloop_cli/project_paths.py +186 -58
  25. fluxloop_cli/templates.py +3 -190
  26. fluxloop_cli/turns.py +28 -3
  27. {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/METADATA +1 -1
  28. fluxloop_cli-0.3.4.dist-info/RECORD +50 -0
  29. {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/WHEEL +1 -1
  30. fluxloop_cli/commands/doctor.py +0 -252
  31. fluxloop_cli/commands/parse.py +0 -460
  32. fluxloop_cli/commands/record.py +0 -156
  33. fluxloop_cli/evaluation/__init__.py +0 -37
  34. fluxloop_cli/evaluation/artifacts.py +0 -115
  35. fluxloop_cli/evaluation/config.py +0 -771
  36. fluxloop_cli/evaluation/engine/__init__.py +0 -7
  37. fluxloop_cli/evaluation/engine/analysis.py +0 -401
  38. fluxloop_cli/evaluation/engine/core.py +0 -404
  39. fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -49
  40. fluxloop_cli/evaluation/engine/reporting/html.py +0 -884
  41. fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -166
  42. fluxloop_cli/evaluation/engine/success.py +0 -325
  43. fluxloop_cli/evaluation/llm.py +0 -397
  44. fluxloop_cli/evaluation/prompts/__init__.py +0 -53
  45. fluxloop_cli/evaluation/prompts/base.py +0 -48
  46. fluxloop_cli/evaluation/prompts/information_completeness.py +0 -45
  47. fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -46
  48. fluxloop_cli/evaluation/prompts/response_clarity.py +0 -44
  49. fluxloop_cli/evaluation/prompts/response_consistency.py +0 -46
  50. fluxloop_cli/evaluation/report/__init__.py +0 -0
  51. fluxloop_cli/evaluation/report/aggregator.py +0 -642
  52. fluxloop_cli/evaluation/report/generator.py +0 -896
  53. fluxloop_cli/evaluation/report/pipeline.py +0 -156
  54. fluxloop_cli/evaluation/report/renderer.py +0 -479
  55. fluxloop_cli/evaluation/rules.py +0 -320
  56. fluxloop_cli/evaluation/templates/report.html.j2 +0 -7072
  57. fluxloop_cli-0.3.2.dist-info/RECORD +0 -76
  58. {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/entry_points.txt +0 -0
  59. {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/top_level.txt +0 -0
fluxloop_cli/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.3.2"
5
+ __version__ = "0.3.4"
6
6
 
7
7
  from .main import app
8
8
 
@@ -77,6 +77,50 @@ def ensure_fluxloop_home() -> Path:
77
77
  return fluxloop_home
78
78
 
79
79
 
80
+ def get_pending_login_path() -> Path:
81
+ """Get the path to the pending login file."""
82
+ return ensure_fluxloop_home() / "pending_login.json"
83
+
84
+
85
+ def save_pending_login(device_response: DeviceCodeResponse, api_url: str) -> Path:
86
+ """Save pending device-code login data for later resume."""
87
+ pending_path = get_pending_login_path()
88
+ now = datetime.now(timezone.utc)
89
+ expires_at = now + timedelta(seconds=device_response.expires_in)
90
+ payload = {
91
+ "api_url": api_url,
92
+ "device_code": device_response.device_code,
93
+ "user_code": device_response.user_code,
94
+ "verification_url": device_response.verification_url,
95
+ "expires_in": device_response.expires_in,
96
+ "interval": device_response.interval,
97
+ "created_at": now.isoformat(),
98
+ "expires_at": expires_at.isoformat(),
99
+ }
100
+ pending_path.write_text(json.dumps(payload, indent=2))
101
+ pending_path.chmod(0o600)
102
+ return pending_path
103
+
104
+
105
+ def load_pending_login() -> Optional[dict]:
106
+ """Load pending login data from ~/.fluxloop/pending_login.json."""
107
+ pending_path = get_pending_login_path()
108
+ if not pending_path.exists():
109
+ return None
110
+ try:
111
+ return json.loads(pending_path.read_text())
112
+ except (json.JSONDecodeError, ValueError) as e:
113
+ print(f"Warning: Failed to load pending login: {e}")
114
+ return None
115
+
116
+
117
+ def delete_pending_login() -> None:
118
+ """Delete pending login data."""
119
+ pending_path = get_pending_login_path()
120
+ if pending_path.exists():
121
+ pending_path.unlink()
122
+
123
+
80
124
  def load_auth_token() -> Optional[AuthToken]:
81
125
  """
82
126
  Load authentication token from ~/.fluxloop/auth.json.
@@ -266,7 +310,7 @@ def poll_device_code(
266
310
  if status == "denied":
267
311
  raise ValueError("User denied authentication")
268
312
  if status == "expired":
269
- raise ValueError("Authentication code has expired")
313
+ raise ValueError("Authentication code has expired")
270
314
  if status not in {"approved", "pending"}:
271
315
  raise ValueError(f"Authentication error: {status}")
272
316
 
@@ -7,16 +7,14 @@ from . import (
7
7
  config,
8
8
  context,
9
9
  criteria,
10
- doctor,
10
+ data,
11
11
  evaluate,
12
12
  generate,
13
13
  init,
14
14
  inputs,
15
15
  local_context,
16
- parse,
17
16
  personas,
18
17
  projects,
19
- record,
20
18
  run,
21
19
  scenarios,
22
20
  status,
@@ -31,16 +29,14 @@ __all__ = [
31
29
  "config",
32
30
  "context",
33
31
  "criteria",
34
- "doctor",
32
+ "data",
35
33
  "evaluate",
36
34
  "generate",
37
35
  "init",
38
36
  "inputs",
39
37
  "local_context",
40
- "parse",
41
38
  "personas",
42
39
  "projects",
43
- "record",
44
40
  "run",
45
41
  "scenarios",
46
42
  "status",
@@ -12,16 +12,32 @@ from typing import Dict, Optional
12
12
  import typer
13
13
  from rich.console import Console
14
14
 
15
- from ..constants import DEFAULT_ROOT_DIR_NAME
15
+ from ..constants import DEFAULT_ROOT_DIR_NAME, FLUXLOOP_DIR_NAME
16
16
  from ..http_client import create_authenticated_client, post_with_retry
17
17
  from ..api_utils import handle_api_error, resolve_api_url
18
18
  from ..context_manager import get_current_project_id
19
- from ..project_paths import resolve_env_path
19
+ from ..project_paths import resolve_env_path, find_workspace_root
20
20
 
21
21
  app = typer.Typer(help="Manage API Keys for sync operations")
22
22
  console = Console()
23
23
 
24
24
 
25
+ def _get_workspace_env_path() -> Optional[Path]:
26
+ """Get the workspace-level .env path (.fluxloop/.env).
27
+
28
+ API keys are project-scoped, so they're stored at workspace level
29
+ and shared by all scenarios.
30
+ """
31
+ workspace_root = find_workspace_root()
32
+ if workspace_root:
33
+ return workspace_root / FLUXLOOP_DIR_NAME / ".env"
34
+ # Fall back to current directory
35
+ cwd_fluxloop = Path.cwd() / FLUXLOOP_DIR_NAME
36
+ if cwd_fluxloop.exists():
37
+ return cwd_fluxloop / ".env"
38
+ return None
39
+
40
+
25
41
  def _check_existing_api_key(env_file: Path) -> Optional[str]:
26
42
  """Check if API Key already exists in .env file."""
27
43
  if not env_file.exists():
@@ -88,21 +104,35 @@ def create_api_key(
88
104
  project_id: Optional[str] = typer.Option(None, "--project-id", help="Project ID"),
89
105
  name: Optional[str] = typer.Option("cli-generated", "--name", "-n", help="Key name"),
90
106
  save: bool = typer.Option(True, "--save/--no-save", help="Save to .env"),
91
- env_file: Path = typer.Option(Path(".env"), "--env-file", help="Env file path"),
107
+ env_file: Optional[Path] = typer.Option(None, "--env-file", help="Override env file path"),
92
108
  overwrite_env: bool = typer.Option(False, "--overwrite-env", help="Overwrite existing"),
93
- project: Optional[str] = typer.Option(None, "--project", help="Project name"),
109
+ project: Optional[str] = typer.Option(None, "--project", help="Project name (legacy)"),
94
110
  root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="Root dir"),
95
111
  api_url: Optional[str] = typer.Option(None, "--api-url", help="API base URL"),
96
112
  ):
97
- """Create an API Key for sync operations (pull/upload)."""
113
+ """Create an API Key for sync operations (pull/upload).
114
+
115
+ Saves to .fluxloop/.env (workspace level) by default.
116
+ API keys are project-scoped and shared by all scenarios.
117
+ """
98
118
  api_url = resolve_api_url(api_url)
99
- env_path = resolve_env_path(env_file, project, root)
119
+
120
+ # Priority: --env-file > workspace .fluxloop/.env > legacy resolution
121
+ if env_file:
122
+ env_path = env_file.expanduser().resolve()
123
+ else:
124
+ env_path = _get_workspace_env_path()
125
+ if env_path:
126
+ console.print(f"[dim]Saving to workspace: {env_path}[/dim]")
127
+ else:
128
+ env_path = resolve_env_path(Path(".env"), project, root)
129
+ console.print(f"[dim]No workspace found, saving to: {env_path}[/dim]")
100
130
 
101
131
  if not project_id:
102
132
  project_id = get_current_project_id()
103
133
  if not project_id:
104
134
  console.print("[yellow]No project selected.[/yellow]")
105
- console.print("[dim]Select: fluxloop context set-project <id>[/dim]")
135
+ console.print("[dim]Select: fluxloop projects select <id>[/dim]")
106
136
  raise typer.Exit(1)
107
137
 
108
138
  # Pre-check existing key
@@ -155,40 +185,48 @@ def create_api_key(
155
185
 
156
186
  @app.command("check")
157
187
  def check_api_key(
158
- env_file: Path = typer.Option(Path(".env"), "--env-file", help="Env file"),
159
- project: Optional[str] = typer.Option(None, "--project", help="Project name"),
188
+ env_file: Optional[Path] = typer.Option(None, "--env-file", help="Override env file"),
189
+ project: Optional[str] = typer.Option(None, "--project", help="Project name (legacy)"),
160
190
  root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="Root dir"),
161
191
  ):
162
- """Check if API Key is configured."""
163
- env_key = os.getenv("FLUXLOOP_SYNC_API_KEY") or os.getenv("FLUXLOOP_API_KEY")
192
+ """Check if API Key is configured.
164
193
 
165
- if env_key:
194
+ Checks in order: environment variable → .fluxloop/.env → legacy paths
195
+ """
196
+ # 1. Check environment variable
197
+ env_key = os.getenv("FLUXLOOP_SYNC_API_KEY") or os.getenv("FLUXLOOP_API_KEY")
198
+ if env_key and env_key != "your-api-key-here":
166
199
  masked = "****" + env_key[-4:] if len(env_key) > 4 else "****"
167
200
  console.print(f"[green]✓[/green] API Key set: {masked}")
168
201
  console.print("[dim]Source: environment variable[/dim]")
169
202
  return
170
203
 
171
- env_path = resolve_env_path(env_file, project, root)
172
-
173
- if env_path.exists():
174
- for line in env_path.read_text().splitlines():
204
+ # 2. Check workspace .fluxloop/.env
205
+ workspace_env = _get_workspace_env_path()
206
+ if workspace_env and workspace_env.exists():
207
+ for line in workspace_env.read_text().splitlines():
175
208
  if line.startswith("FLUXLOOP_SYNC_API_KEY=") or line.startswith("FLUXLOOP_API_KEY="):
176
209
  val = line.split("=", 1)[1].strip()
177
- if val:
210
+ if val and val != "your-api-key-here":
178
211
  masked = "****" + val[-4:] if len(val) > 4 else "****"
179
212
  console.print(f"[green]✓[/green] API Key set: {masked}")
180
- console.print(f"[dim]Source: {env_path}[/dim]")
213
+ console.print(f"[dim]Source: {workspace_env}[/dim]")
181
214
  return
182
215
 
183
- cwd_env = Path.cwd() / ".env"
184
- if cwd_env != env_path and cwd_env.exists():
185
- for line in cwd_env.read_text().splitlines():
216
+ # 3. Check explicit env file or legacy path
217
+ if env_file:
218
+ env_path = env_file.expanduser().resolve()
219
+ else:
220
+ env_path = resolve_env_path(Path(".env"), project, root)
221
+
222
+ if env_path.exists():
223
+ for line in env_path.read_text().splitlines():
186
224
  if line.startswith("FLUXLOOP_SYNC_API_KEY=") or line.startswith("FLUXLOOP_API_KEY="):
187
225
  val = line.split("=", 1)[1].strip()
188
- if val:
226
+ if val and val != "your-api-key-here":
189
227
  masked = "****" + val[-4:] if len(val) > 4 else "****"
190
228
  console.print(f"[green]✓[/green] API Key set: {masked}")
191
- console.print(f"[dim]Source: {cwd_env}[/dim]")
229
+ console.print(f"[dim]Source: {env_path}[/dim]")
192
230
  return
193
231
 
194
232
  console.print("[yellow]✗[/yellow] API Key not set")
@@ -1,9 +1,13 @@
1
1
  """
2
2
  Authentication commands for FluxLoop CLI.
3
+
4
+ Designed for both interactive and agent (Claude Code, Cursor) environments.
3
5
  """
4
6
 
5
7
  import os
8
+ import sys
6
9
  import webbrowser
10
+ from datetime import datetime, timezone
7
11
  from typing import Optional
8
12
 
9
13
  import typer
@@ -18,7 +22,11 @@ from ..auth_manager import (
18
22
  save_auth_token,
19
23
  load_auth_token,
20
24
  delete_auth_token,
25
+ save_pending_login,
26
+ load_pending_login,
27
+ delete_pending_login,
21
28
  is_token_expired,
29
+ ensure_valid_token,
22
30
  format_expires_at,
23
31
  )
24
32
 
@@ -38,44 +46,191 @@ def _resolve_api_url(override: Optional[str]) -> str:
38
46
  return url.rstrip("/")
39
47
 
40
48
 
49
+ def _is_agent_environment() -> bool:
50
+ """Detect if running in an agent/headless environment."""
51
+ # Check common agent environment indicators
52
+ if not sys.stdout.isatty():
53
+ return True
54
+ if os.getenv("CLAUDE_CODE") or os.getenv("CURSOR_AGENT"):
55
+ return True
56
+ if os.getenv("CI") or os.getenv("GITHUB_ACTIONS"):
57
+ return True
58
+ return False
59
+
60
+
41
61
  @app.command()
42
62
  def login(
43
63
  api_url: Optional[str] = typer.Option(
44
64
  None, "--api-url", help="FluxLoop API base URL"
45
65
  ),
66
+ force: bool = typer.Option(
67
+ False, "--force", "-f", help="Force re-login even if already logged in"
68
+ ),
69
+ no_browser: bool = typer.Option(
70
+ False, "--no-browser", help="Don't open browser automatically (for CI/CD or headless environments)"
71
+ ),
72
+ no_wait: bool = typer.Option(
73
+ False,
74
+ "--no-wait",
75
+ help="Print device code and exit without polling (use --resume to finish)",
76
+ ),
77
+ resume: bool = typer.Option(
78
+ False,
79
+ "--resume",
80
+ help="Resume a pending login by polling saved device code",
81
+ ),
46
82
  ):
47
83
  """
48
84
  Login to connect your FluxLoop account.
85
+
86
+ If already logged in with a valid token, this command will skip login
87
+ unless --force is specified. This makes the command idempotent and
88
+ safe to call from automated scripts or AI agents.
49
89
  """
50
90
  api_url = _resolve_api_url(api_url)
51
91
 
92
+ # Check if already logged in (idempotent behavior)
93
+ if not force:
94
+ existing_token = load_auth_token()
95
+ if existing_token and not is_token_expired(existing_token):
96
+ console.print(f"[green]✓[/green] Already logged in: [bold]{existing_token.user_email}[/bold]")
97
+ time_remaining = format_expires_at(existing_token.expires_at)
98
+ console.print(f" Token expires: {time_remaining}")
99
+ console.print("[dim]Use --force to re-login with a new session.[/dim]")
100
+ return
101
+
102
+ if resume:
103
+ pending = load_pending_login()
104
+ if not pending:
105
+ console.print("[red]✗[/red] No pending login found. Run 'fluxloop auth login --no-wait' first.")
106
+ print("LOGIN_STATUS: no_pending")
107
+ raise typer.Exit(1)
108
+
109
+ pending_api_url = pending.get("api_url") or api_url
110
+ device_code = pending.get("device_code")
111
+ if not device_code:
112
+ console.print("[red]✗[/red] Pending login data is missing device_code.")
113
+ delete_pending_login()
114
+ print("LOGIN_STATUS: invalid_pending")
115
+ raise typer.Exit(1)
116
+
117
+ interval = pending.get("interval") or 5
118
+ timeout = pending.get("expires_in") or 300
119
+ expires_at = pending.get("expires_at")
120
+ if expires_at:
121
+ try:
122
+ expires_dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
123
+ if expires_dt.tzinfo is None:
124
+ expires_dt = expires_dt.replace(tzinfo=timezone.utc)
125
+ remaining = int((expires_dt - datetime.now(timezone.utc)).total_seconds())
126
+ if remaining <= 0:
127
+ delete_pending_login()
128
+ console.print("[red]✗[/red] Pending login has expired. Run 'fluxloop auth login --no-wait' again.")
129
+ print("LOGIN_STATUS: expired_pending")
130
+ raise typer.Exit(1)
131
+ timeout = remaining
132
+ except ValueError:
133
+ pass
134
+
135
+ try:
136
+ # Poll for completion
137
+ token = poll_device_code(
138
+ pending_api_url,
139
+ device_code,
140
+ interval=interval,
141
+ timeout=timeout,
142
+ )
143
+
144
+ save_auth_token(token)
145
+ delete_pending_login()
146
+
147
+ print()
148
+ print(f"LOGIN_SUCCESS: {token.user_email}")
149
+ console.print(f"[green]✓[/green] Login successful: [bold]{token.user_email}[/bold]")
150
+ console.print()
151
+ console.print("[dim]Next steps:[/dim]")
152
+ console.print("[dim] • fluxloop projects list - View your projects[/dim]")
153
+ console.print("[dim] • fluxloop context set-project <id> - Select a project[/dim]")
154
+ return
155
+ except TimeoutError as e:
156
+ delete_pending_login()
157
+ console.print(f"\n[red]✗[/red] {e}", style="bold red")
158
+ raise typer.Exit(1)
159
+ except ValueError as e:
160
+ delete_pending_login()
161
+ console.print(f"\n[red]✗[/red] {e}", style="bold red")
162
+ raise typer.Exit(1)
163
+ except Exception as e:
164
+ delete_pending_login()
165
+ console.print(f"\n[red]✗[/red] Login failed: {e}", style="bold red")
166
+ raise typer.Exit(1)
167
+
168
+ # Detect agent environment for adjusted behavior
169
+ is_agent = _is_agent_environment()
170
+ skip_browser = no_browser
171
+
52
172
  try:
53
173
  # Start device-code flow
54
174
  device_response = start_device_code_flow(api_url)
55
175
 
56
- # Display login panel
57
- login_panel = Panel(
58
- f"[bold cyan]Open this URL in your browser:[/bold cyan]\n"
59
- f"{device_response.verification_url}\n\n"
60
- f"[bold yellow]Enter code:[/bold yellow] [bold green]{device_response.user_code}[/bold green]",
61
- title="[bold blue]FluxLoop CLI Login[/bold blue]",
62
- border_style="blue",
63
- )
64
- console.print(login_panel)
65
- console.print()
176
+ # Always print plain text for agent compatibility (parseable output)
177
+ # Use flush=True to ensure output appears immediately in agent environments
178
+ print(flush=True)
179
+ print("=" * 50, flush=True)
180
+ print("FLUXLOOP LOGIN", flush=True)
181
+ print("=" * 50, flush=True)
182
+ print(f"URL: {device_response.verification_url}", flush=True)
183
+ print(f"CODE: {device_response.user_code}", flush=True)
184
+ print("=" * 50, flush=True)
185
+ print(flush=True)
66
186
 
67
- # Try to open browser automatically
68
- try:
69
- webbrowser.open(device_response.verification_url)
70
- console.print("[dim]Browser opened automatically...[/dim]")
71
- except Exception:
72
- console.print("[dim]Please open the browser manually.[/dim]")
187
+ # Also display rich panel for interactive terminals
188
+ if sys.stdout.isatty():
189
+ login_panel = Panel(
190
+ f"[bold cyan]Open this URL in your browser:[/bold cyan]\n"
191
+ f"{device_response.verification_url}\n\n"
192
+ f"[bold yellow]Enter code:[/bold yellow] [bold green]{device_response.user_code}[/bold green]",
193
+ title="[bold blue]FluxLoop CLI Login[/bold blue]",
194
+ border_style="blue",
195
+ )
196
+ console.print(login_panel)
197
+ console.print()
73
198
 
74
- console.print()
199
+ # Save pending login info for agent/non-blocking resume
200
+ pending_path = save_pending_login(device_response, api_url)
201
+
202
+ # Try to open browser automatically (unless disabled with --no-browser)
203
+ if not skip_browser:
204
+ try:
205
+ webbrowser.open(device_response.verification_url)
206
+ console.print("[dim]Browser opened automatically...[/dim]")
207
+ except Exception:
208
+ console.print("[dim]Please open the browser manually.[/dim]")
209
+ else:
210
+ print("(Browser auto-open disabled with --no-browser)", flush=True)
211
+
212
+ print(flush=True)
213
+ print("Waiting for approval...", flush=True)
75
214
 
76
- # Poll for completion with spinner
77
- spinner = Spinner("dots", text="Waiting for approval...")
78
- with Live(spinner, console=console, refresh_per_second=10):
215
+ if no_wait:
216
+ print(f"PENDING_LOGIN_PATH: {pending_path}", flush=True)
217
+ console.print("[yellow]Login pending.[/yellow] Open the URL, enter the code, then run:")
218
+ console.print("[dim] fluxloop auth login --resume[/dim]")
219
+ return
220
+
221
+ # Poll for completion
222
+ if sys.stdout.isatty() and not is_agent:
223
+ # Use spinner for interactive terminals
224
+ spinner = Spinner("dots", text="Waiting for approval...")
225
+ with Live(spinner, console=console, refresh_per_second=10):
226
+ token = poll_device_code(
227
+ api_url,
228
+ device_response.device_code,
229
+ interval=device_response.interval,
230
+ timeout=device_response.expires_in,
231
+ )
232
+ else:
233
+ # Simple polling for agents (no spinner)
79
234
  token = poll_device_code(
80
235
  api_url,
81
236
  device_response.device_code,
@@ -85,9 +240,11 @@ def login(
85
240
 
86
241
  # Save token
87
242
  save_auth_token(token)
243
+ delete_pending_login()
88
244
 
89
245
  # Success message (User-scoped, no project selection at login)
90
- console.print()
246
+ print()
247
+ print(f"LOGIN_SUCCESS: {token.user_email}")
91
248
  console.print(f"[green]✓[/green] Login successful: [bold]{token.user_email}[/bold]")
92
249
  console.print()
93
250
  console.print("[dim]Next steps:[/dim]")
@@ -95,12 +252,15 @@ def login(
95
252
  console.print("[dim] • fluxloop context set-project <id> - Select a project[/dim]")
96
253
 
97
254
  except TimeoutError as e:
255
+ delete_pending_login()
98
256
  console.print(f"\n[red]✗[/red] {e}", style="bold red")
99
257
  raise typer.Exit(1)
100
258
  except ValueError as e:
259
+ delete_pending_login()
101
260
  console.print(f"\n[red]✗[/red] {e}", style="bold red")
102
261
  raise typer.Exit(1)
103
262
  except Exception as e:
263
+ delete_pending_login()
104
264
  console.print(f"\n[red]✗[/red] Login failed: {e}", style="bold red")
105
265
  raise typer.Exit(1)
106
266
 
@@ -114,42 +274,72 @@ def logout():
114
274
 
115
275
  if not token:
116
276
  console.print("[yellow]Already logged out.[/yellow]")
277
+ print("LOGOUT_STATUS: already_logged_out")
117
278
  return
118
279
 
119
280
  delete_auth_token()
120
281
  console.print("[green]✓[/green] Logged out successfully")
282
+ print("LOGOUT_STATUS: success")
121
283
 
122
284
 
123
285
  @app.command()
124
286
  def status():
125
287
  """
126
288
  Check current login status.
289
+
290
+ If token is expired or about to expire, automatically attempts to refresh.
291
+ Only suggests re-login if refresh fails.
127
292
  """
293
+ api_url = _resolve_api_url(None)
128
294
  token = load_auth_token()
129
295
 
130
296
  if not token:
131
297
  console.print("[yellow]Not logged in.[/yellow]")
132
298
  console.print("[dim]Run [bold]fluxloop auth login[/bold] to login.[/dim]")
299
+ print("AUTH_STATUS: not_logged_in")
133
300
  return
134
301
 
135
- # Check expiration
302
+ # Check expiration - if expired, try refresh first
136
303
  expired = is_token_expired(token)
137
- time_remaining = format_expires_at(token.expires_at)
138
-
304
+
139
305
  if expired:
140
- console.print("[red]✗[/red] Token has expired")
141
- console.print("[dim]Run [bold]fluxloop auth login[/bold] to login again.[/dim]")
306
+ # Try to refresh before declaring "expired"
307
+ console.print("[dim]Token expired or expiring soon, attempting refresh...[/dim]")
308
+ refreshed_token = ensure_valid_token(api_url)
309
+
310
+ if refreshed_token:
311
+ # Refresh succeeded
312
+ token = refreshed_token
313
+ time_remaining = format_expires_at(token.expires_at)
314
+ console.print(f"[green]✓[/green] Logged in: [bold]{token.user_email}[/bold] (token refreshed)")
315
+ console.print(f" Token expires: {time_remaining}")
316
+ print(f"AUTH_STATUS: logged_in (refreshed)")
317
+ print(f"AUTH_USER: {token.user_email}")
318
+ else:
319
+ # Refresh failed
320
+ console.print("[red]✗[/red] Token expired and refresh failed")
321
+ console.print("[dim]Run [bold]fluxloop auth login[/bold] to login again.[/dim]")
322
+ print("AUTH_STATUS: expired")
323
+ return
142
324
  else:
325
+ time_remaining = format_expires_at(token.expires_at)
143
326
  console.print(f"[green]✓[/green] Logged in: [bold]{token.user_email}[/bold]")
144
327
  console.print(f" Token expires: {time_remaining}")
145
-
146
- # Show current context if available
147
- from ..context_manager import load_context
148
- context = load_context()
149
- if context:
150
- if context.current_project:
151
- console.print(f" Project: {context.current_project.name} ([dim]{context.current_project.id}[/dim])")
152
- if context.current_scenario:
153
- console.print(f" Scenario: {context.current_scenario.name} ([dim]{context.current_scenario.id}[/dim])")
154
- else:
155
- console.print("[dim] No project selected. Run [bold]fluxloop projects list[/bold] to see your projects.[/dim]")
328
+ print(f"AUTH_STATUS: logged_in")
329
+ print(f"AUTH_USER: {token.user_email}")
330
+
331
+ # Show current context if available
332
+ from ..context_manager import load_context, load_project_connection
333
+
334
+ project_conn = load_project_connection()
335
+ if project_conn:
336
+ console.print(f" Project: {project_conn.project_name} ([dim]{project_conn.project_id}[/dim])")
337
+ print(f"AUTH_PROJECT: {project_conn.project_id}")
338
+
339
+ context = load_context()
340
+ if context and context.current_scenario:
341
+ console.print(f" Scenario: {context.current_scenario.name} ([dim]{context.current_scenario.id}[/dim])")
342
+ print(f"AUTH_SCENARIO: {context.current_scenario.id}")
343
+
344
+ if not project_conn:
345
+ console.print("[dim] No project selected. Run [bold]fluxloop projects select[/bold] to choose a project.[/dim]")