fluxloop-cli 0.3.2__tar.gz → 0.3.4__tar.gz

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 (104) hide show
  1. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/PKG-INFO +1 -1
  2. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/__init__.py +1 -1
  3. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/auth_manager.py +45 -1
  4. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/__init__.py +2 -6
  5. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/apikeys.py +61 -23
  6. fluxloop_cli-0.3.4/fluxloop_cli/commands/auth.py +345 -0
  7. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/bundles.py +64 -11
  8. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/context.py +22 -2
  9. fluxloop_cli-0.3.4/fluxloop_cli/commands/data.py +657 -0
  10. fluxloop_cli-0.3.4/fluxloop_cli/commands/evaluate.py +188 -0
  11. fluxloop_cli-0.3.4/fluxloop_cli/commands/init.py +275 -0
  12. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/inputs.py +178 -20
  13. fluxloop_cli-0.3.4/fluxloop_cli/commands/local_context.py +167 -0
  14. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/personas.py +50 -56
  15. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/projects.py +39 -18
  16. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/scenarios.py +149 -13
  17. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/sync.py +185 -56
  18. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/test.py +136 -42
  19. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/config_loader.py +12 -14
  20. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/config_schema.py +3 -8
  21. fluxloop_cli-0.3.4/fluxloop_cli/constants.py +49 -0
  22. fluxloop_cli-0.3.4/fluxloop_cli/context_manager.py +583 -0
  23. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/conversation_supervisor.py +63 -0
  24. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/main.py +3 -7
  25. fluxloop_cli-0.3.4/fluxloop_cli/project_paths.py +259 -0
  26. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/templates.py +3 -190
  27. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/turns.py +28 -3
  28. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/PKG-INFO +1 -1
  29. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/SOURCES.txt +1 -27
  30. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/pyproject.toml +1 -1
  31. fluxloop_cli-0.3.2/fluxloop_cli/commands/auth.py +0 -155
  32. fluxloop_cli-0.3.2/fluxloop_cli/commands/doctor.py +0 -252
  33. fluxloop_cli-0.3.2/fluxloop_cli/commands/evaluate.py +0 -264
  34. fluxloop_cli-0.3.2/fluxloop_cli/commands/init.py +0 -361
  35. fluxloop_cli-0.3.2/fluxloop_cli/commands/local_context.py +0 -171
  36. fluxloop_cli-0.3.2/fluxloop_cli/commands/parse.py +0 -460
  37. fluxloop_cli-0.3.2/fluxloop_cli/commands/record.py +0 -156
  38. fluxloop_cli-0.3.2/fluxloop_cli/constants.py +0 -27
  39. fluxloop_cli-0.3.2/fluxloop_cli/context_manager.py +0 -225
  40. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/__init__.py +0 -37
  41. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/artifacts.py +0 -115
  42. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/config.py +0 -771
  43. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/engine/__init__.py +0 -7
  44. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/engine/analysis.py +0 -401
  45. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/engine/core.py +0 -404
  46. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -49
  47. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/engine/reporting/html.py +0 -884
  48. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -166
  49. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/engine/success.py +0 -325
  50. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/llm.py +0 -397
  51. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/prompts/__init__.py +0 -53
  52. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/prompts/base.py +0 -48
  53. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -45
  54. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -46
  55. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -44
  56. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -46
  57. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/report/__init__.py +0 -0
  58. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/report/aggregator.py +0 -642
  59. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/report/generator.py +0 -896
  60. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/report/pipeline.py +0 -156
  61. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/report/renderer.py +0 -479
  62. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/rules.py +0 -320
  63. fluxloop_cli-0.3.2/fluxloop_cli/evaluation/templates/report.html.j2 +0 -7072
  64. fluxloop_cli-0.3.2/fluxloop_cli/project_paths.py +0 -131
  65. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/README.md +0 -0
  66. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/api_utils.py +0 -0
  67. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/arg_binder.py +0 -0
  68. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/config.py +0 -0
  69. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/criteria.py +0 -0
  70. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/generate.py +0 -0
  71. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/run.py +0 -0
  72. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/status.py +0 -0
  73. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/common_options.py +0 -0
  74. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/environment.py +0 -0
  75. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/http_client.py +0 -0
  76. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/input_generator.py +0 -0
  77. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/llm_generator.py +0 -0
  78. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/runner.py +0 -0
  79. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/target_loader.py +0 -0
  80. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/__init__.py +0 -0
  81. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/pytest_plugin.py +0 -0
  82. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/types.py +0 -0
  83. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/token_usage.py +0 -0
  84. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli/validators.py +0 -0
  85. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  86. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  87. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/requires.txt +0 -0
  88. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/top_level.txt +0 -0
  89. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/setup.cfg +0 -0
  90. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_analysis_recommendations.py +0 -0
  91. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_arg_binder.py +0 -0
  92. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_config_command.py +0 -0
  93. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_conversation_supervisor.py +0 -0
  94. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_evaluate_command.py +0 -0
  95. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_evaluation_llm.py +0 -0
  96. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_init_pytest_template.py +0 -0
  97. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_input_generator.py +0 -0
  98. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_prompt_library.py +0 -0
  99. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_pytest_plugin.py +0 -0
  100. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_run_command.py +0 -0
  101. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_runner_multi_turn.py +0 -0
  102. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_success_criteria.py +0 -0
  103. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_target_loader.py +0 -0
  104. {fluxloop_cli-0.3.2 → fluxloop_cli-0.3.4}/tests/test_testing_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: FluxLoop CLI for running agent simulations
5
5
  Author-email: FluxLoop Team <team@fluxloop.dev>
6
6
  License: Apache-2.0
@@ -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")
@@ -0,0 +1,345 @@
1
+ """
2
+ Authentication commands for FluxLoop CLI.
3
+
4
+ Designed for both interactive and agent (Claude Code, Cursor) environments.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import webbrowser
10
+ from datetime import datetime, timezone
11
+ from typing import Optional
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.spinner import Spinner
17
+ from rich.live import Live
18
+
19
+ from ..auth_manager import (
20
+ start_device_code_flow,
21
+ poll_device_code,
22
+ save_auth_token,
23
+ load_auth_token,
24
+ delete_auth_token,
25
+ save_pending_login,
26
+ load_pending_login,
27
+ delete_pending_login,
28
+ is_token_expired,
29
+ ensure_valid_token,
30
+ format_expires_at,
31
+ )
32
+
33
+
34
+ app = typer.Typer(help="Manage FluxLoop authentication")
35
+ console = Console()
36
+
37
+
38
+ def _resolve_api_url(override: Optional[str]) -> str:
39
+ """Resolve API URL from override or environment variables."""
40
+ url = (
41
+ override
42
+ or os.getenv("FLUXLOOP_API_URL")
43
+ or os.getenv("FLUXLOOP_SYNC_URL")
44
+ or "https://api.fluxloop.ai"
45
+ )
46
+ return url.rstrip("/")
47
+
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
+
61
+ @app.command()
62
+ def login(
63
+ api_url: Optional[str] = typer.Option(
64
+ None, "--api-url", help="FluxLoop API base URL"
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
+ ),
82
+ ):
83
+ """
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.
89
+ """
90
+ api_url = _resolve_api_url(api_url)
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
+
172
+ try:
173
+ # Start device-code flow
174
+ device_response = start_device_code_flow(api_url)
175
+
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)
186
+
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()
198
+
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)
214
+
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)
234
+ token = poll_device_code(
235
+ api_url,
236
+ device_response.device_code,
237
+ interval=device_response.interval,
238
+ timeout=device_response.expires_in,
239
+ )
240
+
241
+ # Save token
242
+ save_auth_token(token)
243
+ delete_pending_login()
244
+
245
+ # Success message (User-scoped, no project selection at login)
246
+ print()
247
+ print(f"LOGIN_SUCCESS: {token.user_email}")
248
+ console.print(f"[green]✓[/green] Login successful: [bold]{token.user_email}[/bold]")
249
+ console.print()
250
+ console.print("[dim]Next steps:[/dim]")
251
+ console.print("[dim] • fluxloop projects list - View your projects[/dim]")
252
+ console.print("[dim] • fluxloop context set-project <id> - Select a project[/dim]")
253
+
254
+ except TimeoutError as e:
255
+ delete_pending_login()
256
+ console.print(f"\n[red]✗[/red] {e}", style="bold red")
257
+ raise typer.Exit(1)
258
+ except ValueError as e:
259
+ delete_pending_login()
260
+ console.print(f"\n[red]✗[/red] {e}", style="bold red")
261
+ raise typer.Exit(1)
262
+ except Exception as e:
263
+ delete_pending_login()
264
+ console.print(f"\n[red]✗[/red] Login failed: {e}", style="bold red")
265
+ raise typer.Exit(1)
266
+
267
+
268
+ @app.command()
269
+ def logout():
270
+ """
271
+ Logout and delete saved authentication information.
272
+ """
273
+ token = load_auth_token()
274
+
275
+ if not token:
276
+ console.print("[yellow]Already logged out.[/yellow]")
277
+ print("LOGOUT_STATUS: already_logged_out")
278
+ return
279
+
280
+ delete_auth_token()
281
+ console.print("[green]✓[/green] Logged out successfully")
282
+ print("LOGOUT_STATUS: success")
283
+
284
+
285
+ @app.command()
286
+ def status():
287
+ """
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.
292
+ """
293
+ api_url = _resolve_api_url(None)
294
+ token = load_auth_token()
295
+
296
+ if not token:
297
+ console.print("[yellow]Not logged in.[/yellow]")
298
+ console.print("[dim]Run [bold]fluxloop auth login[/bold] to login.[/dim]")
299
+ print("AUTH_STATUS: not_logged_in")
300
+ return
301
+
302
+ # Check expiration - if expired, try refresh first
303
+ expired = is_token_expired(token)
304
+
305
+ if expired:
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
324
+ else:
325
+ time_remaining = format_expires_at(token.expires_at)
326
+ console.print(f"[green]✓[/green] Logged in: [bold]{token.user_email}[/bold]")
327
+ console.print(f" Token expires: {time_remaining}")
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]")