fluxloop-cli 0.3.1__tar.gz → 0.3.3__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 (101) hide show
  1. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/PKG-INFO +3 -2
  2. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/__init__.py +1 -1
  3. fluxloop_cli-0.3.3/fluxloop_cli/api_utils.py +208 -0
  4. fluxloop_cli-0.3.3/fluxloop_cli/auth_manager.py +410 -0
  5. fluxloop_cli-0.3.3/fluxloop_cli/commands/__init__.py +41 -0
  6. fluxloop_cli-0.3.3/fluxloop_cli/commands/apikeys.py +233 -0
  7. fluxloop_cli-0.3.3/fluxloop_cli/commands/auth.py +345 -0
  8. fluxloop_cli-0.3.3/fluxloop_cli/commands/bundles.py +408 -0
  9. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/config.py +1 -2
  10. fluxloop_cli-0.3.3/fluxloop_cli/commands/context.py +119 -0
  11. fluxloop_cli-0.3.3/fluxloop_cli/commands/criteria.py +58 -0
  12. fluxloop_cli-0.3.3/fluxloop_cli/commands/init.py +275 -0
  13. fluxloop_cli-0.3.3/fluxloop_cli/commands/inputs.py +546 -0
  14. fluxloop_cli-0.3.3/fluxloop_cli/commands/local_context.py +167 -0
  15. fluxloop_cli-0.3.3/fluxloop_cli/commands/personas.py +251 -0
  16. fluxloop_cli-0.3.3/fluxloop_cli/commands/projects.py +249 -0
  17. fluxloop_cli-0.3.3/fluxloop_cli/commands/scenarios.py +536 -0
  18. fluxloop_cli-0.3.3/fluxloop_cli/commands/sync.py +843 -0
  19. fluxloop_cli-0.3.3/fluxloop_cli/commands/test.py +465 -0
  20. fluxloop_cli-0.3.3/fluxloop_cli/common_options.py +73 -0
  21. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/config_loader.py +33 -6
  22. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/config_schema.py +3 -8
  23. fluxloop_cli-0.3.3/fluxloop_cli/constants.py +49 -0
  24. fluxloop_cli-0.3.3/fluxloop_cli/context_manager.py +583 -0
  25. fluxloop_cli-0.3.3/fluxloop_cli/http_client.py +157 -0
  26. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/main.py +40 -8
  27. fluxloop_cli-0.3.3/fluxloop_cli/project_paths.py +259 -0
  28. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/runner.py +164 -6
  29. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/templates.py +231 -191
  30. fluxloop_cli-0.3.3/fluxloop_cli/turns.py +259 -0
  31. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/PKG-INFO +3 -2
  32. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/SOURCES.txt +17 -28
  33. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/requires.txt +2 -1
  34. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/pyproject.toml +3 -2
  35. fluxloop_cli-0.3.1/fluxloop_cli/commands/__init__.py +0 -16
  36. fluxloop_cli-0.3.1/fluxloop_cli/commands/doctor.py +0 -252
  37. fluxloop_cli-0.3.1/fluxloop_cli/commands/evaluate.py +0 -264
  38. fluxloop_cli-0.3.1/fluxloop_cli/commands/init.py +0 -317
  39. fluxloop_cli-0.3.1/fluxloop_cli/commands/parse.py +0 -460
  40. fluxloop_cli-0.3.1/fluxloop_cli/commands/record.py +0 -156
  41. fluxloop_cli-0.3.1/fluxloop_cli/commands/sync.py +0 -488
  42. fluxloop_cli-0.3.1/fluxloop_cli/constants.py +0 -27
  43. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/__init__.py +0 -37
  44. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/artifacts.py +0 -115
  45. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/config.py +0 -771
  46. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/__init__.py +0 -7
  47. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/analysis.py +0 -401
  48. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/core.py +0 -404
  49. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -49
  50. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/reporting/html.py +0 -884
  51. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -166
  52. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/success.py +0 -325
  53. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/llm.py +0 -397
  54. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/__init__.py +0 -53
  55. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/base.py +0 -48
  56. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -45
  57. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -46
  58. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -44
  59. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -46
  60. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/__init__.py +0 -0
  61. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/aggregator.py +0 -642
  62. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/generator.py +0 -896
  63. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/pipeline.py +0 -156
  64. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/renderer.py +0 -479
  65. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/rules.py +0 -320
  66. fluxloop_cli-0.3.1/fluxloop_cli/evaluation/templates/report.html.j2 +0 -7072
  67. fluxloop_cli-0.3.1/fluxloop_cli/project_paths.py +0 -131
  68. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/README.md +0 -0
  69. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/arg_binder.py +0 -0
  70. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/generate.py +0 -0
  71. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/run.py +0 -0
  72. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/status.py +0 -0
  73. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/conversation_supervisor.py +0 -0
  74. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/environment.py +0 -0
  75. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/input_generator.py +0 -0
  76. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/llm_generator.py +0 -0
  77. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/target_loader.py +0 -0
  78. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/testing/__init__.py +0 -0
  79. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/testing/pytest_plugin.py +0 -0
  80. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/testing/types.py +0 -0
  81. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/token_usage.py +0 -0
  82. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/validators.py +0 -0
  83. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  84. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  85. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/top_level.txt +0 -0
  86. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/setup.cfg +0 -0
  87. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_analysis_recommendations.py +0 -0
  88. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_arg_binder.py +0 -0
  89. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_config_command.py +0 -0
  90. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_conversation_supervisor.py +0 -0
  91. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_evaluate_command.py +0 -0
  92. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_evaluation_llm.py +0 -0
  93. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_init_pytest_template.py +0 -0
  94. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_input_generator.py +0 -0
  95. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_prompt_library.py +0 -0
  96. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_pytest_plugin.py +0 -0
  97. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_run_command.py +0 -0
  98. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_runner_multi_turn.py +0 -0
  99. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_success_criteria.py +0 -0
  100. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_target_loader.py +0 -0
  101. {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/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.1
3
+ Version: 0.3.3
4
4
  Summary: FluxLoop CLI for running agent simulations
5
5
  Author-email: FluxLoop Team <team@fluxloop.dev>
6
6
  License: Apache-2.0
@@ -19,11 +19,12 @@ Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Requires-Python: >=3.8
21
21
  Description-Content-Type: text/markdown
22
- Requires-Dist: typer[all]>=0.9.0
22
+ Requires-Dist: typer>=0.9.0
23
23
  Requires-Dist: pydantic>=2.0
24
24
  Requires-Dist: pyyaml>=6.0
25
25
  Requires-Dist: httpx>=0.24.0
26
26
  Requires-Dist: rich>=13.0
27
+ Requires-Dist: shellingham>=1.5.0
27
28
  Requires-Dist: python-dotenv>=1.0.0
28
29
  Requires-Dist: fluxloop>=0.1.0
29
30
  Requires-Dist: ruamel.yaml>=0.17.0
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.3.1"
5
+ __version__ = "0.3.3"
6
6
 
7
7
  from .main import app
8
8
 
@@ -0,0 +1,208 @@
1
+ """
2
+ Common API utilities for FluxLoop CLI commands.
3
+
4
+ Provides reusable functions for API URL resolution, payload handling,
5
+ cache management, and error handling.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ import httpx
16
+ import typer
17
+ import yaml
18
+ from rich.console import Console
19
+
20
+ console = Console()
21
+
22
+
23
+ def resolve_api_url(override: Optional[str]) -> str:
24
+ """
25
+ Resolve API URL from override or environment variables.
26
+
27
+ Priority:
28
+ 1. Override parameter
29
+ 2. FLUXLOOP_API_URL
30
+ 3. FLUXLOOP_SYNC_URL
31
+ 4. Default: https://api.fluxloop.ai
32
+
33
+ Args:
34
+ override: Optional URL override.
35
+
36
+ Returns:
37
+ Resolved API URL (trailing slash removed).
38
+ """
39
+ url = (
40
+ override
41
+ or os.getenv("FLUXLOOP_API_URL")
42
+ or os.getenv("FLUXLOOP_SYNC_URL")
43
+ or "https://api.fluxloop.ai"
44
+ )
45
+ return url.rstrip("/")
46
+
47
+
48
+ def load_payload_file(file_path: Path) -> Dict[str, Any]:
49
+ """
50
+ Load payload from YAML or JSON file.
51
+
52
+ Args:
53
+ file_path: Path to YAML or JSON file.
54
+
55
+ Returns:
56
+ Parsed payload dictionary.
57
+
58
+ Raises:
59
+ typer.BadParameter: If file doesn't exist, has invalid extension, or parsing fails.
60
+ """
61
+ if not file_path.exists():
62
+ raise typer.BadParameter(f"File not found: {file_path}")
63
+
64
+ suffix = file_path.suffix.lower()
65
+
66
+ try:
67
+ content = file_path.read_text()
68
+
69
+ if suffix in [".yaml", ".yml"]:
70
+ data = yaml.safe_load(content)
71
+ elif suffix == ".json":
72
+ data = json.loads(content)
73
+ else:
74
+ raise typer.BadParameter(
75
+ f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json"
76
+ )
77
+
78
+ if not isinstance(data, dict):
79
+ raise typer.BadParameter(
80
+ f"File must contain a dictionary/object, got {type(data).__name__}"
81
+ )
82
+
83
+ return data
84
+
85
+ except yaml.YAMLError as e:
86
+ raise typer.BadParameter(f"Invalid YAML: {e}")
87
+ except json.JSONDecodeError as e:
88
+ raise typer.BadParameter(f"Invalid JSON: {e}")
89
+
90
+
91
+ def ensure_cache_dir(subdir: str) -> Path:
92
+ """
93
+ Ensure ~/.fluxloop/{subdir} directory exists and return its path.
94
+
95
+ Args:
96
+ subdir: Subdirectory name under ~/.fluxloop/.
97
+
98
+ Returns:
99
+ Path to cache directory.
100
+ """
101
+ cache_dir = Path.home() / ".fluxloop" / subdir
102
+ cache_dir.mkdir(parents=True, exist_ok=True)
103
+ return cache_dir
104
+
105
+
106
+ def save_cache_file(subdir: str, filename: str, data: dict) -> Path:
107
+ """
108
+ Save data to cache file in YAML format.
109
+
110
+ Args:
111
+ subdir: Subdirectory name under ~/.fluxloop/.
112
+ filename: Cache file name.
113
+ data: Dictionary to save.
114
+
115
+ Returns:
116
+ Path to saved cache file.
117
+ """
118
+ cache_dir = ensure_cache_dir(subdir)
119
+ cache_path = cache_dir / filename
120
+
121
+ cache_path.write_text(yaml.dump(data, indent=2, allow_unicode=True))
122
+
123
+ return cache_path
124
+
125
+
126
+ def handle_api_error(resp: httpx.Response, context: str) -> None:
127
+ """
128
+ Handle common API error responses with helpful messages.
129
+
130
+ Args:
131
+ resp: HTTP response to check.
132
+ context: Context description for error messages (e.g., "scenario", "persona").
133
+
134
+ Raises:
135
+ typer.Exit: If response indicates an error.
136
+ """
137
+ if resp.status_code == 401:
138
+ console.print(
139
+ "[red]✗[/red] Authentication required. Run [bold]fluxloop auth login[/bold] or login again if token expired.",
140
+ style="bold red",
141
+ )
142
+ raise typer.Exit(1)
143
+
144
+ elif resp.status_code == 403:
145
+ console.print(
146
+ "[red]✗[/red] Permission denied. Check your project access permissions.",
147
+ style="bold red",
148
+ )
149
+ raise typer.Exit(1)
150
+
151
+ elif resp.status_code == 404:
152
+ console.print(
153
+ f"[red]✗[/red] Resource not found: {context}",
154
+ style="bold red",
155
+ )
156
+ raise typer.Exit(1)
157
+
158
+ elif resp.status_code == 422:
159
+ # Try to extract validation errors
160
+ try:
161
+ error_data = resp.json()
162
+ if "detail" in error_data:
163
+ detail = error_data["detail"]
164
+ if isinstance(detail, list):
165
+ # FastAPI validation errors
166
+ errors = []
167
+ for err in detail:
168
+ field = " -> ".join(str(loc) for loc in err.get("loc", []))
169
+ msg = err.get("msg", "")
170
+ errors.append(f" - {field}: {msg}")
171
+ console.print(
172
+ "[red]✗[/red] Invalid request data:",
173
+ style="bold red",
174
+ )
175
+ for err in errors:
176
+ console.print(err)
177
+ else:
178
+ console.print(
179
+ f"[red]✗[/red] Invalid request data: {detail}",
180
+ style="bold red",
181
+ )
182
+ else:
183
+ console.print(
184
+ f"[red]✗[/red] Invalid request data: {resp.text}",
185
+ style="bold red",
186
+ )
187
+ except Exception:
188
+ console.print(
189
+ "[red]✗[/red] Invalid request data",
190
+ style="bold red",
191
+ )
192
+ raise typer.Exit(1)
193
+
194
+ elif resp.status_code >= 500:
195
+ console.print(
196
+ "[red]✗[/red] Server error. Please try again later.",
197
+ style="bold red",
198
+ )
199
+ console.print(f"[dim]Status: {resp.status_code}[/dim]")
200
+ raise typer.Exit(1)
201
+
202
+ elif not resp.is_success:
203
+ console.print(
204
+ f"[red]✗[/red] Request failed: {resp.status_code}",
205
+ style="bold red",
206
+ )
207
+ console.print(f"[dim]{resp.text}[/dim]")
208
+ raise typer.Exit(1)
@@ -0,0 +1,410 @@
1
+ """
2
+ Authentication token management for FluxLoop CLI.
3
+
4
+ Handles device-code flow authentication, token storage, and refresh logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta, timezone
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import httpx
18
+
19
+
20
+ @dataclass
21
+ class AuthToken:
22
+ """
23
+ Authentication token data structure.
24
+
25
+ User-scoped JWT: project_id is NOT included in the token.
26
+ Project selection is handled via local context (.fluxloop/context.json).
27
+ """
28
+
29
+ access_token: str
30
+ refresh_token: str
31
+ expires_at: str # ISO 8601 format
32
+ user_id: str
33
+ user_email: str
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: dict) -> AuthToken:
37
+ """Create AuthToken from dictionary."""
38
+ return cls(
39
+ access_token=data["access_token"],
40
+ refresh_token=data["refresh_token"],
41
+ expires_at=data["expires_at"],
42
+ user_id=data["user_id"],
43
+ user_email=data.get("user_email", ""),
44
+ )
45
+
46
+ def to_dict(self) -> dict:
47
+ """Convert AuthToken to dictionary."""
48
+ return {
49
+ "access_token": self.access_token,
50
+ "refresh_token": self.refresh_token,
51
+ "expires_at": self.expires_at,
52
+ "user_id": self.user_id,
53
+ "user_email": self.user_email,
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class DeviceCodeResponse:
59
+ """Device code flow initiation response."""
60
+
61
+ device_code: str
62
+ user_code: str
63
+ verification_url: str
64
+ expires_in: int
65
+ interval: int
66
+
67
+
68
+ def get_auth_token_path() -> Path:
69
+ """Get the path to the auth token file."""
70
+ return ensure_fluxloop_home() / "auth.json"
71
+
72
+
73
+ def ensure_fluxloop_home() -> Path:
74
+ """Ensure ~/.fluxloop/ directory exists and return its path."""
75
+ fluxloop_home = Path.home() / ".fluxloop"
76
+ fluxloop_home.mkdir(parents=True, exist_ok=True)
77
+ return fluxloop_home
78
+
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
+
124
+ def load_auth_token() -> Optional[AuthToken]:
125
+ """
126
+ Load authentication token from ~/.fluxloop/auth.json.
127
+
128
+ Returns:
129
+ AuthToken if found and valid, None otherwise.
130
+ """
131
+ token_path = get_auth_token_path()
132
+
133
+ if not token_path.exists():
134
+ return None
135
+
136
+ try:
137
+ data = json.loads(token_path.read_text())
138
+ return AuthToken.from_dict(data)
139
+ except (json.JSONDecodeError, KeyError, ValueError) as e:
140
+ # Log error but don't crash - treat as missing token
141
+ print(f"Warning: Failed to load auth token: {e}")
142
+ return None
143
+
144
+
145
+ def save_auth_token(token: AuthToken) -> None:
146
+ """
147
+ Save authentication token to ~/.fluxloop/auth.json with secure permissions.
148
+
149
+ Args:
150
+ token: AuthToken to save.
151
+ """
152
+ token_path = get_auth_token_path()
153
+
154
+ # Write token data
155
+ token_path.write_text(json.dumps(token.to_dict(), indent=2))
156
+
157
+ # Set file permissions to 600 (read/write for owner only)
158
+ token_path.chmod(0o600)
159
+
160
+
161
+ def delete_auth_token() -> None:
162
+ """Delete the authentication token file."""
163
+ token_path = get_auth_token_path()
164
+
165
+ if token_path.exists():
166
+ token_path.unlink()
167
+
168
+
169
+ def is_token_expired(token: AuthToken) -> bool:
170
+ """
171
+ Check if the token is expired or will expire soon.
172
+
173
+ Uses a 5-minute buffer to allow for network latency and refresh time.
174
+
175
+ Args:
176
+ token: AuthToken to check.
177
+
178
+ Returns:
179
+ True if token is expired or will expire within 5 minutes.
180
+ """
181
+ try:
182
+ # Parse ISO 8601 timestamp
183
+ expires_at = datetime.fromisoformat(token.expires_at.replace("Z", "+00:00"))
184
+
185
+ # Add timezone info if missing
186
+ if expires_at.tzinfo is None:
187
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
188
+
189
+ # Current time with 5-minute buffer
190
+ now_with_buffer = datetime.now(timezone.utc) + timedelta(minutes=5)
191
+
192
+ return expires_at <= now_with_buffer
193
+ except (ValueError, AttributeError) as e:
194
+ # If we can't parse the timestamp, treat as expired
195
+ print(f"Warning: Failed to parse token expiry: {e}")
196
+ return True
197
+
198
+
199
+ def format_expires_at(expires_at: str) -> str:
200
+ """
201
+ Format expiry timestamp as human-readable string.
202
+
203
+ Args:
204
+ expires_at: ISO 8601 timestamp string.
205
+
206
+ Returns:
207
+ Formatted string like "2h 30m remaining" or "expired".
208
+ """
209
+ try:
210
+ expires = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
211
+
212
+ # Add timezone info if missing
213
+ if expires.tzinfo is None:
214
+ expires = expires.replace(tzinfo=timezone.utc)
215
+
216
+ now = datetime.now(timezone.utc)
217
+ delta = expires - now
218
+
219
+ if delta.total_seconds() <= 0:
220
+ return "expired"
221
+
222
+ # Convert to hours and minutes
223
+ total_minutes = int(delta.total_seconds() // 60)
224
+ hours = total_minutes // 60
225
+ minutes = total_minutes % 60
226
+
227
+ if hours > 0:
228
+ return f"{hours}h {minutes}m remaining"
229
+ else:
230
+ return f"{minutes}m remaining"
231
+ except (ValueError, AttributeError):
232
+ return "unknown"
233
+
234
+
235
+ def start_device_code_flow(api_url: str) -> DeviceCodeResponse:
236
+ """
237
+ Start device-code authentication flow.
238
+
239
+ Args:
240
+ api_url: Base URL of the FluxLoop API.
241
+
242
+ Returns:
243
+ DeviceCodeResponse with device_code, user_code, and verification URL.
244
+
245
+ Raises:
246
+ httpx.HTTPError: If API request fails.
247
+ """
248
+ url = api_url.rstrip("/") + "/api/cli-connect/start"
249
+
250
+ with httpx.Client(timeout=10.0) as client:
251
+ resp = client.post(url, json={})
252
+ resp.raise_for_status()
253
+ data = resp.json()
254
+
255
+ expires_in = data.get("expires_in")
256
+ if expires_in is None:
257
+ expires_in = data.get("expires_in_sec", 600)
258
+ interval = data.get("interval")
259
+ if interval is None:
260
+ interval = data.get("interval_sec", 5)
261
+
262
+ return DeviceCodeResponse(
263
+ device_code=data["device_code"],
264
+ user_code=data["user_code"],
265
+ verification_url=data["verification_url"],
266
+ expires_in=expires_in,
267
+ interval=interval,
268
+ )
269
+
270
+
271
+ def poll_device_code(
272
+ api_url: str, device_code: str, interval: int = 5, timeout: int = 300
273
+ ) -> AuthToken:
274
+ """
275
+ Poll for device-code completion.
276
+
277
+ Args:
278
+ api_url: Base URL of the FluxLoop API.
279
+ device_code: Device code from start_device_code_flow.
280
+ interval: Polling interval in seconds.
281
+ timeout: Maximum time to wait in seconds (default: 5 minutes).
282
+
283
+ Returns:
284
+ AuthToken when user approves authentication.
285
+
286
+ Raises:
287
+ TimeoutError: If polling times out.
288
+ ValueError: If user denies authentication or code expires.
289
+ httpx.HTTPError: If API request fails.
290
+ """
291
+ url = api_url.rstrip("/") + "/api/cli-connect/complete"
292
+ start_time = time.time()
293
+
294
+ with httpx.Client(timeout=10.0) as client:
295
+ while True:
296
+ elapsed = time.time() - start_time
297
+ if elapsed > timeout:
298
+ raise TimeoutError("Authentication timeout exceeded")
299
+
300
+ resp = client.post(url, json={"device_code": device_code})
301
+ resp.raise_for_status()
302
+ data = resp.json()
303
+
304
+ # Check for pending status
305
+ if data.get("status") == "pending":
306
+ time.sleep(interval)
307
+ continue
308
+
309
+ status = data.get("status")
310
+ if status == "denied":
311
+ raise ValueError("User denied authentication")
312
+ if status == "expired":
313
+ raise ValueError("Authentication code has expired")
314
+ if status not in {"approved", "pending"}:
315
+ raise ValueError(f"Authentication error: {status}")
316
+
317
+ credential = data.get("credential") or {}
318
+ access_token = credential.get("access_token")
319
+ if not access_token:
320
+ raise ValueError("Authentication response missing access token")
321
+
322
+ expires_at = credential.get("expires_at")
323
+ if not expires_at:
324
+ expires_at = (
325
+ datetime.now(timezone.utc) + timedelta(seconds=3600)
326
+ ).isoformat()
327
+
328
+ return AuthToken(
329
+ access_token=access_token,
330
+ refresh_token=credential.get("refresh_token", ""),
331
+ expires_at=expires_at,
332
+ user_id=credential.get("user_id", ""),
333
+ user_email=credential.get("user_email", ""),
334
+ )
335
+
336
+
337
+ def refresh_access_token(api_url: str, refresh_token: str) -> AuthToken:
338
+ """
339
+ Refresh access token using refresh token.
340
+
341
+ Args:
342
+ api_url: Base URL of the FluxLoop API.
343
+ refresh_token: Refresh token from previous authentication.
344
+
345
+ Returns:
346
+ New AuthToken with refreshed access_token.
347
+
348
+ Raises:
349
+ ValueError: If refresh token is invalid.
350
+ httpx.HTTPError: If API request fails.
351
+ """
352
+ url = api_url.rstrip("/") + "/api/cli-connect/refresh"
353
+
354
+ with httpx.Client(timeout=10.0) as client:
355
+ resp = client.post(url, json={"refresh_token": refresh_token})
356
+ resp.raise_for_status()
357
+ data = resp.json()
358
+
359
+ credential = data.get("credential") or {}
360
+ access_token = credential.get("access_token")
361
+ if not access_token:
362
+ raise ValueError("Token refresh failed. Please login again.")
363
+
364
+ # Load existing token to preserve user info if response omits it
365
+ existing_token = load_auth_token()
366
+ user_id = credential.get("user_id") or (existing_token.user_id if existing_token else "")
367
+ user_email = credential.get("user_email") or (existing_token.user_email if existing_token else "")
368
+
369
+ expires_at = credential.get("expires_at")
370
+ if not expires_at:
371
+ expires_at = (
372
+ datetime.now(timezone.utc) + timedelta(seconds=3600)
373
+ ).isoformat()
374
+
375
+ return AuthToken(
376
+ access_token=access_token,
377
+ refresh_token=credential.get("refresh_token", refresh_token),
378
+ expires_at=expires_at,
379
+ user_id=user_id,
380
+ user_email=user_email,
381
+ )
382
+
383
+
384
+ def ensure_valid_token(api_url: str) -> Optional[AuthToken]:
385
+ """
386
+ Ensure a valid token exists, refreshing if necessary.
387
+
388
+ Args:
389
+ api_url: Base URL of the FluxLoop API.
390
+
391
+ Returns:
392
+ Valid AuthToken, or None if unable to get/refresh token.
393
+ """
394
+ # Load existing token
395
+ token = load_auth_token()
396
+ if not token:
397
+ return None
398
+
399
+ # Check if token is expired
400
+ if not is_token_expired(token):
401
+ return token
402
+
403
+ # Try to refresh
404
+ try:
405
+ refreshed_token = refresh_access_token(api_url, token.refresh_token)
406
+ save_auth_token(refreshed_token)
407
+ return refreshed_token
408
+ except Exception as e:
409
+ print(f"Warning: Failed to refresh token: {e}")
410
+ return None
@@ -0,0 +1,41 @@
1
+ """CLI commands."""
2
+
3
+ from . import (
4
+ apikeys,
5
+ auth,
6
+ bundles,
7
+ config,
8
+ context,
9
+ criteria,
10
+ generate,
11
+ init,
12
+ inputs,
13
+ local_context,
14
+ personas,
15
+ projects,
16
+ run,
17
+ scenarios,
18
+ status,
19
+ sync,
20
+ test,
21
+ )
22
+
23
+ __all__ = [
24
+ "apikeys",
25
+ "auth",
26
+ "bundles",
27
+ "config",
28
+ "context",
29
+ "criteria",
30
+ "generate",
31
+ "init",
32
+ "inputs",
33
+ "local_context",
34
+ "personas",
35
+ "projects",
36
+ "run",
37
+ "scenarios",
38
+ "status",
39
+ "sync",
40
+ "test",
41
+ ]