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.
- fluxloop_cli/__init__.py +1 -1
- fluxloop_cli/auth_manager.py +45 -1
- fluxloop_cli/commands/__init__.py +2 -6
- fluxloop_cli/commands/apikeys.py +61 -23
- fluxloop_cli/commands/auth.py +227 -37
- fluxloop_cli/commands/bundles.py +64 -11
- fluxloop_cli/commands/context.py +22 -2
- fluxloop_cli/commands/data.py +657 -0
- fluxloop_cli/commands/evaluate.py +165 -241
- fluxloop_cli/commands/init.py +159 -245
- fluxloop_cli/commands/inputs.py +178 -20
- fluxloop_cli/commands/local_context.py +69 -73
- fluxloop_cli/commands/personas.py +50 -56
- fluxloop_cli/commands/projects.py +39 -18
- fluxloop_cli/commands/scenarios.py +149 -13
- fluxloop_cli/commands/sync.py +185 -56
- fluxloop_cli/commands/test.py +136 -42
- fluxloop_cli/config_loader.py +12 -14
- fluxloop_cli/config_schema.py +3 -8
- fluxloop_cli/constants.py +31 -9
- fluxloop_cli/context_manager.py +445 -87
- fluxloop_cli/conversation_supervisor.py +63 -0
- fluxloop_cli/main.py +3 -7
- fluxloop_cli/project_paths.py +186 -58
- fluxloop_cli/templates.py +3 -190
- fluxloop_cli/turns.py +28 -3
- {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/METADATA +1 -1
- fluxloop_cli-0.3.4.dist-info/RECORD +50 -0
- {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/WHEEL +1 -1
- fluxloop_cli/commands/doctor.py +0 -252
- fluxloop_cli/commands/parse.py +0 -460
- fluxloop_cli/commands/record.py +0 -156
- fluxloop_cli/evaluation/__init__.py +0 -37
- fluxloop_cli/evaluation/artifacts.py +0 -115
- fluxloop_cli/evaluation/config.py +0 -771
- fluxloop_cli/evaluation/engine/__init__.py +0 -7
- fluxloop_cli/evaluation/engine/analysis.py +0 -401
- fluxloop_cli/evaluation/engine/core.py +0 -404
- fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -49
- fluxloop_cli/evaluation/engine/reporting/html.py +0 -884
- fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -166
- fluxloop_cli/evaluation/engine/success.py +0 -325
- fluxloop_cli/evaluation/llm.py +0 -397
- fluxloop_cli/evaluation/prompts/__init__.py +0 -53
- fluxloop_cli/evaluation/prompts/base.py +0 -48
- fluxloop_cli/evaluation/prompts/information_completeness.py +0 -45
- fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -46
- fluxloop_cli/evaluation/prompts/response_clarity.py +0 -44
- fluxloop_cli/evaluation/prompts/response_consistency.py +0 -46
- fluxloop_cli/evaluation/report/__init__.py +0 -0
- fluxloop_cli/evaluation/report/aggregator.py +0 -642
- fluxloop_cli/evaluation/report/generator.py +0 -896
- fluxloop_cli/evaluation/report/pipeline.py +0 -156
- fluxloop_cli/evaluation/report/renderer.py +0 -479
- fluxloop_cli/evaluation/rules.py +0 -320
- fluxloop_cli/evaluation/templates/report.html.j2 +0 -7072
- fluxloop_cli-0.3.2.dist-info/RECORD +0 -76
- {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/entry_points.txt +0 -0
- {fluxloop_cli-0.3.2.dist-info → fluxloop_cli-0.3.4.dist-info}/top_level.txt +0 -0
fluxloop_cli/__init__.py
CHANGED
fluxloop_cli/auth_manager.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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",
|
fluxloop_cli/commands/apikeys.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
if
|
|
174
|
-
for line in
|
|
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: {
|
|
213
|
+
console.print(f"[dim]Source: {workspace_env}[/dim]")
|
|
181
214
|
return
|
|
182
215
|
|
|
183
|
-
|
|
184
|
-
if
|
|
185
|
-
|
|
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: {
|
|
229
|
+
console.print(f"[dim]Source: {env_path}[/dim]")
|
|
192
230
|
return
|
|
193
231
|
|
|
194
232
|
console.print("[yellow]✗[/yellow] API Key not set")
|
fluxloop_cli/commands/auth.py
CHANGED
|
@@ -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
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
304
|
+
|
|
139
305
|
if expired:
|
|
140
|
-
|
|
141
|
-
console.print("[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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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]")
|