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.
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/PKG-INFO +3 -2
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/__init__.py +1 -1
- fluxloop_cli-0.3.3/fluxloop_cli/api_utils.py +208 -0
- fluxloop_cli-0.3.3/fluxloop_cli/auth_manager.py +410 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/__init__.py +41 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/apikeys.py +233 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/auth.py +345 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/bundles.py +408 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/config.py +1 -2
- fluxloop_cli-0.3.3/fluxloop_cli/commands/context.py +119 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/criteria.py +58 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/init.py +275 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/inputs.py +546 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/local_context.py +167 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/personas.py +251 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/projects.py +249 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/scenarios.py +536 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/sync.py +843 -0
- fluxloop_cli-0.3.3/fluxloop_cli/commands/test.py +465 -0
- fluxloop_cli-0.3.3/fluxloop_cli/common_options.py +73 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/config_loader.py +33 -6
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/config_schema.py +3 -8
- fluxloop_cli-0.3.3/fluxloop_cli/constants.py +49 -0
- fluxloop_cli-0.3.3/fluxloop_cli/context_manager.py +583 -0
- fluxloop_cli-0.3.3/fluxloop_cli/http_client.py +157 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/main.py +40 -8
- fluxloop_cli-0.3.3/fluxloop_cli/project_paths.py +259 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/runner.py +164 -6
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/templates.py +231 -191
- fluxloop_cli-0.3.3/fluxloop_cli/turns.py +259 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/PKG-INFO +3 -2
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/SOURCES.txt +17 -28
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/requires.txt +2 -1
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/pyproject.toml +3 -2
- fluxloop_cli-0.3.1/fluxloop_cli/commands/__init__.py +0 -16
- fluxloop_cli-0.3.1/fluxloop_cli/commands/doctor.py +0 -252
- fluxloop_cli-0.3.1/fluxloop_cli/commands/evaluate.py +0 -264
- fluxloop_cli-0.3.1/fluxloop_cli/commands/init.py +0 -317
- fluxloop_cli-0.3.1/fluxloop_cli/commands/parse.py +0 -460
- fluxloop_cli-0.3.1/fluxloop_cli/commands/record.py +0 -156
- fluxloop_cli-0.3.1/fluxloop_cli/commands/sync.py +0 -488
- fluxloop_cli-0.3.1/fluxloop_cli/constants.py +0 -27
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/__init__.py +0 -37
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/artifacts.py +0 -115
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/config.py +0 -771
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/__init__.py +0 -7
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/analysis.py +0 -401
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/core.py +0 -404
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -49
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/reporting/html.py +0 -884
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -166
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/engine/success.py +0 -325
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/llm.py +0 -397
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/__init__.py +0 -53
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/base.py +0 -48
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -45
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -46
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -44
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -46
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/__init__.py +0 -0
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/aggregator.py +0 -642
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/generator.py +0 -896
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/pipeline.py +0 -156
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/report/renderer.py +0 -479
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/rules.py +0 -320
- fluxloop_cli-0.3.1/fluxloop_cli/evaluation/templates/report.html.j2 +0 -7072
- fluxloop_cli-0.3.1/fluxloop_cli/project_paths.py +0 -131
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/README.md +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/arg_binder.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/run.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/conversation_supervisor.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/environment.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/input_generator.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/testing/__init__.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/testing/pytest_plugin.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/testing/types.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/token_usage.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/setup.cfg +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_analysis_recommendations.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_arg_binder.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_conversation_supervisor.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_evaluate_command.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_evaluation_llm.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_init_pytest_template.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_prompt_library.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_pytest_plugin.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_run_command.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_runner_multi_turn.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_success_criteria.py +0 -0
- {fluxloop_cli-0.3.1 → fluxloop_cli-0.3.3}/tests/test_target_loader.py +0 -0
- {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.
|
|
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
|
|
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
|
|
@@ -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
|
+
]
|