haliosai-cli 2.0.1__tar.gz → 2.0.2__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.
- {haliosai_cli-2.0.1/haliosai_cli.egg-info → haliosai_cli-2.0.2}/PKG-INFO +1 -1
- haliosai_cli-2.0.2/halios_cli/_version.py +1 -0
- haliosai_cli-2.0.2/halios_cli/cli_auth.py +171 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2/haliosai_cli.egg-info}/PKG-INFO +1 -1
- haliosai_cli-2.0.1/halios_cli/_version.py +0 -1
- haliosai_cli-2.0.1/halios_cli/cli_auth.py +0 -162
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/LICENSE +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/MANIFEST.in +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/README.md +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/__init__.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/cli.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/cli_eval.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/cli_optimize.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/cli_project.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/cli_scenario.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/cli_support.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/cli_trace.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/py.typed +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/schemas/__init__.py +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/schemas/eval.schema.json +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/halios_cli/schemas/scenarios.schema.json +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/haliosai_cli.egg-info/SOURCES.txt +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/haliosai_cli.egg-info/dependency_links.txt +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/haliosai_cli.egg-info/entry_points.txt +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/haliosai_cli.egg-info/requires.txt +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/haliosai_cli.egg-info/top_level.txt +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/pyproject.toml +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/setup.cfg +0 -0
- {haliosai_cli-2.0.1 → haliosai_cli-2.0.2}/tests/test_cli_surface.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.2"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""User-level CLI authentication commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .cli_support import (
|
|
11
|
+
DEFAULT_BASE_URL,
|
|
12
|
+
ApiClient,
|
|
13
|
+
ApiError,
|
|
14
|
+
delete_profile,
|
|
15
|
+
normalize_url,
|
|
16
|
+
resolve_credentials,
|
|
17
|
+
save_profile,
|
|
18
|
+
stored_profile_credentials,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(help="Authenticate the Halios CLI.", no_args_is_help=True)
|
|
22
|
+
|
|
23
|
+
# INVARIANT: Match the server-side poll interval from CLIAuthRequestResponse.interval.
|
|
24
|
+
_POLL_INTERVAL_S = 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("login")
|
|
28
|
+
def login(
|
|
29
|
+
profile: str = typer.Option("default", "--profile"),
|
|
30
|
+
base_url: str = typer.Option(DEFAULT_BASE_URL, "--base-url"),
|
|
31
|
+
api_key: str | None = typer.Option(None, "--api-key", envvar="HALIOS_API_KEY", hidden=True),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Authorize this machine and store credentials outside the repository.
|
|
34
|
+
|
|
35
|
+
Opens your browser to complete authorization. No local HTTP server is
|
|
36
|
+
started; the CLI polls the Halios API until you approve or the session expires.
|
|
37
|
+
"""
|
|
38
|
+
normalized_url = normalize_url(base_url)
|
|
39
|
+
|
|
40
|
+
# Fast path: explicit API key provided (e.g. CI environments or manual --api-key flag).
|
|
41
|
+
if api_key:
|
|
42
|
+
save_profile(profile, base_url=normalized_url, api_key=api_key)
|
|
43
|
+
typer.echo(f"Logged in as profile '{profile}'.")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# --- Step 1: Request a device-code session from the backend ---
|
|
47
|
+
try:
|
|
48
|
+
with httpx.Client(timeout=10) as http:
|
|
49
|
+
resp = http.post(
|
|
50
|
+
f"{normalized_url}/api/v1/cli-auth/request",
|
|
51
|
+
params={"base_url": normalized_url},
|
|
52
|
+
)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
session_data = resp.json()
|
|
55
|
+
except httpx.HTTPStatusError as exc:
|
|
56
|
+
if exc.response.content:
|
|
57
|
+
detail = exc.response.json().get("detail", exc.response.text)
|
|
58
|
+
else:
|
|
59
|
+
detail = str(exc)
|
|
60
|
+
raise typer.BadParameter(f"Could not start login session: {detail}") from exc
|
|
61
|
+
except httpx.HTTPError as exc:
|
|
62
|
+
raise typer.BadParameter(
|
|
63
|
+
f"Could not reach {normalized_url}. Check your network or --base-url."
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
device_code: str = session_data["device_code"]
|
|
67
|
+
# INTENT: CLI builds verification_url itself so it uses its own configured base_url,
|
|
68
|
+
# not whatever the server returned (which may differ in local dev setups).
|
|
69
|
+
verification_url = f"{normalized_url}/cli-auth?code={device_code}&profile={profile}"
|
|
70
|
+
expires_in: int = session_data.get("expires_in", 300)
|
|
71
|
+
|
|
72
|
+
# --- Step 2: Open the browser and tell the user what's happening ---
|
|
73
|
+
typer.echo(f"\nOpening your browser to authorize the CLI:\n {verification_url}\n")
|
|
74
|
+
typer.echo("Waiting for authorization in browser… (Ctrl+C to cancel)\n")
|
|
75
|
+
|
|
76
|
+
import webbrowser
|
|
77
|
+
webbrowser.open(verification_url)
|
|
78
|
+
|
|
79
|
+
# --- Step 3: Poll until authorized, denied, or expired ---
|
|
80
|
+
poll_url = f"{normalized_url}/api/v1/cli-auth/poll"
|
|
81
|
+
deadline = time.monotonic() + expires_in
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with httpx.Client(timeout=10) as http:
|
|
85
|
+
while time.monotonic() < deadline:
|
|
86
|
+
time.sleep(_POLL_INTERVAL_S)
|
|
87
|
+
try:
|
|
88
|
+
poll_resp = http.get(poll_url, params={"code": device_code})
|
|
89
|
+
poll_resp.raise_for_status()
|
|
90
|
+
result = poll_resp.json()
|
|
91
|
+
except httpx.HTTPError:
|
|
92
|
+
# WHY: Transient network errors should not abort the wait.
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
poll_status: str = result.get("status", "expired")
|
|
96
|
+
|
|
97
|
+
if poll_status == "pending":
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if poll_status == "authorized":
|
|
101
|
+
save_profile(
|
|
102
|
+
profile,
|
|
103
|
+
base_url=normalized_url,
|
|
104
|
+
api_key=result["api_key"],
|
|
105
|
+
organization_id=result.get("organization_id"),
|
|
106
|
+
api_key_id=int(result["api_key_id"]) if result.get("api_key_id") else None,
|
|
107
|
+
expires_at=result.get("expires_at"),
|
|
108
|
+
)
|
|
109
|
+
typer.echo(f"Logged in as profile '{profile}'.")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if poll_status == "denied":
|
|
113
|
+
raise typer.BadParameter("Authorization was cancelled in the browser.")
|
|
114
|
+
|
|
115
|
+
# status == "expired" or unknown
|
|
116
|
+
raise typer.BadParameter(
|
|
117
|
+
"Login session expired. Please run 'halios auth login' again."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
except KeyboardInterrupt:
|
|
121
|
+
typer.echo("\nLogin cancelled.")
|
|
122
|
+
raise typer.Exit(code=1)
|
|
123
|
+
|
|
124
|
+
raise typer.BadParameter("Login timed out. Please run 'halios auth login' again.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("status")
|
|
128
|
+
def status(profile: str = typer.Option("default", "--profile")) -> None:
|
|
129
|
+
"""Verify the selected profile without exposing its credential."""
|
|
130
|
+
credentials = resolve_credentials(profile)
|
|
131
|
+
with ApiClient(credentials) as api:
|
|
132
|
+
api.request("GET", "/api/v1/agents", params={"limit": 1})
|
|
133
|
+
expiry = f" Credential expires {credentials.expires_at}." if credentials.expires_at else ""
|
|
134
|
+
typer.echo(f"Authenticated profile '{profile}' at {credentials.base_url}.{expiry}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("logout")
|
|
138
|
+
def logout(
|
|
139
|
+
profile: str = typer.Option("default", "--profile"),
|
|
140
|
+
local_only: bool = typer.Option(
|
|
141
|
+
False,
|
|
142
|
+
"--local-only",
|
|
143
|
+
help="Remove local credentials without revoking the remote key.",
|
|
144
|
+
),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Revoke one CLI credential, then remove its local profile."""
|
|
147
|
+
credentials = stored_profile_credentials(profile)
|
|
148
|
+
if credentials is None:
|
|
149
|
+
typer.echo(f"Profile '{profile}' was not stored.")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if not local_only:
|
|
153
|
+
try:
|
|
154
|
+
with ApiClient(credentials) as api:
|
|
155
|
+
api.request("DELETE", "/api/v1/api-keys/current")
|
|
156
|
+
except ApiError as exc:
|
|
157
|
+
if exc.status_code != 401:
|
|
158
|
+
raise typer.BadParameter(
|
|
159
|
+
"Remote revocation failed; local credentials were kept. "
|
|
160
|
+
"Retry, or use --local-only if the server is permanently unavailable."
|
|
161
|
+
) from exc
|
|
162
|
+
typer.echo("The remote credential was already invalid or expired.")
|
|
163
|
+
except httpx.HTTPError as exc:
|
|
164
|
+
raise typer.BadParameter(
|
|
165
|
+
"Could not reach Halios to revoke the credential; local credentials were kept. "
|
|
166
|
+
"Retry, or use --local-only if the server is permanently unavailable."
|
|
167
|
+
) from exc
|
|
168
|
+
|
|
169
|
+
delete_profile(profile)
|
|
170
|
+
suffix = " locally (remote key unchanged)" if local_only else " and revoked its API key"
|
|
171
|
+
typer.echo(f"Removed profile '{profile}'{suffix}.")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.0.1"
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
"""User-level CLI authentication commands."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import secrets
|
|
7
|
-
import threading
|
|
8
|
-
import urllib.parse
|
|
9
|
-
import webbrowser
|
|
10
|
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
-
|
|
12
|
-
import httpx
|
|
13
|
-
import typer
|
|
14
|
-
|
|
15
|
-
from .cli_support import (
|
|
16
|
-
DEFAULT_BASE_URL,
|
|
17
|
-
ApiClient,
|
|
18
|
-
ApiError,
|
|
19
|
-
delete_profile,
|
|
20
|
-
normalize_url,
|
|
21
|
-
resolve_credentials,
|
|
22
|
-
save_profile,
|
|
23
|
-
stored_profile_credentials,
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
app = typer.Typer(help="Authenticate the Halios CLI.", no_args_is_help=True)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@app.command("login")
|
|
30
|
-
def login(
|
|
31
|
-
profile: str = typer.Option("default", "--profile"),
|
|
32
|
-
base_url: str = typer.Option(DEFAULT_BASE_URL, "--base-url"),
|
|
33
|
-
api_key: str | None = typer.Option(None, "--api-key", envvar="HALIOS_API_KEY", hidden=True),
|
|
34
|
-
) -> None:
|
|
35
|
-
"""Authorize this machine and store credentials outside the repository."""
|
|
36
|
-
normalized_url = normalize_url(base_url)
|
|
37
|
-
if api_key:
|
|
38
|
-
save_profile(profile, base_url=normalized_url, api_key=api_key)
|
|
39
|
-
typer.echo(f"Logged in as profile '{profile}'.")
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
result: dict[str, str] = {}
|
|
43
|
-
completed = threading.Event()
|
|
44
|
-
state = secrets.token_urlsafe(24)
|
|
45
|
-
|
|
46
|
-
class CallbackHandler(BaseHTTPRequestHandler):
|
|
47
|
-
def _cors(self) -> None:
|
|
48
|
-
self.send_header("Access-Control-Allow-Origin", normalized_url)
|
|
49
|
-
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
50
|
-
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
51
|
-
|
|
52
|
-
def do_OPTIONS(self) -> None: # noqa: N802
|
|
53
|
-
self.send_response(204)
|
|
54
|
-
self._cors()
|
|
55
|
-
self.end_headers()
|
|
56
|
-
|
|
57
|
-
def do_POST(self) -> None: # noqa: N802
|
|
58
|
-
if self.path != "/callback":
|
|
59
|
-
self.send_error(404)
|
|
60
|
-
return
|
|
61
|
-
length = int(self.headers.get("content-length") or 0)
|
|
62
|
-
try:
|
|
63
|
-
body = json.loads(self.rfile.read(length))
|
|
64
|
-
except (ValueError, UnicodeDecodeError):
|
|
65
|
-
self.send_error(400)
|
|
66
|
-
return
|
|
67
|
-
if not isinstance(body, dict) or not secrets.compare_digest(
|
|
68
|
-
str(body.get("state") or ""), state
|
|
69
|
-
):
|
|
70
|
-
self.send_error(422)
|
|
71
|
-
return
|
|
72
|
-
if body.get("error"):
|
|
73
|
-
result["error"] = str(body["error"])
|
|
74
|
-
elif body.get("api_key"):
|
|
75
|
-
result.update({key: str(value) for key, value in body.items() if value is not None})
|
|
76
|
-
else:
|
|
77
|
-
self.send_error(422)
|
|
78
|
-
return
|
|
79
|
-
self.send_response(204)
|
|
80
|
-
self._cors()
|
|
81
|
-
self.end_headers()
|
|
82
|
-
completed.set()
|
|
83
|
-
|
|
84
|
-
def log_message(self, _format: str, *_args: object) -> None:
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
server = ThreadingHTTPServer(("127.0.0.1", 0), CallbackHandler)
|
|
88
|
-
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
89
|
-
thread.start()
|
|
90
|
-
login_url = f"{normalized_url}/cli-login?" + urllib.parse.urlencode(
|
|
91
|
-
{"port": server.server_port, "profile": profile, "state": state}
|
|
92
|
-
)
|
|
93
|
-
typer.echo(f"Open this URL to authorize the CLI:\n{login_url}")
|
|
94
|
-
webbrowser.open(login_url)
|
|
95
|
-
try:
|
|
96
|
-
if not completed.wait(timeout=300):
|
|
97
|
-
raise typer.BadParameter("Login timed out after 5 minutes")
|
|
98
|
-
finally:
|
|
99
|
-
server.shutdown()
|
|
100
|
-
server.server_close()
|
|
101
|
-
|
|
102
|
-
if result.get("error") == "access_denied":
|
|
103
|
-
raise typer.BadParameter("Authorization was cancelled in the browser")
|
|
104
|
-
if result.get("error"):
|
|
105
|
-
raise typer.BadParameter(f"Authorization failed: {result['error']}")
|
|
106
|
-
|
|
107
|
-
save_profile(
|
|
108
|
-
profile,
|
|
109
|
-
base_url=normalized_url,
|
|
110
|
-
api_key=result["api_key"],
|
|
111
|
-
organization_id=result.get("organization_id"),
|
|
112
|
-
api_key_id=int(result["api_key_id"]) if result.get("api_key_id") else None,
|
|
113
|
-
expires_at=result.get("expires_at"),
|
|
114
|
-
)
|
|
115
|
-
typer.echo(f"Logged in as profile '{profile}'.")
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
@app.command("status")
|
|
119
|
-
def status(profile: str = typer.Option("default", "--profile")) -> None:
|
|
120
|
-
"""Verify the selected profile without exposing its credential."""
|
|
121
|
-
credentials = resolve_credentials(profile)
|
|
122
|
-
with ApiClient(credentials) as api:
|
|
123
|
-
api.request("GET", "/api/v1/agents", params={"limit": 1})
|
|
124
|
-
expiry = f" Credential expires {credentials.expires_at}." if credentials.expires_at else ""
|
|
125
|
-
typer.echo(f"Authenticated profile '{profile}' at {credentials.base_url}.{expiry}")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
@app.command("logout")
|
|
129
|
-
def logout(
|
|
130
|
-
profile: str = typer.Option("default", "--profile"),
|
|
131
|
-
local_only: bool = typer.Option(
|
|
132
|
-
False,
|
|
133
|
-
"--local-only",
|
|
134
|
-
help="Remove local credentials without revoking the remote key.",
|
|
135
|
-
),
|
|
136
|
-
) -> None:
|
|
137
|
-
"""Revoke one CLI credential, then remove its local profile."""
|
|
138
|
-
credentials = stored_profile_credentials(profile)
|
|
139
|
-
if credentials is None:
|
|
140
|
-
typer.echo(f"Profile '{profile}' was not stored.")
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
if not local_only:
|
|
144
|
-
try:
|
|
145
|
-
with ApiClient(credentials) as api:
|
|
146
|
-
api.request("DELETE", "/api/v1/api-keys/current")
|
|
147
|
-
except ApiError as exc:
|
|
148
|
-
if exc.status_code != 401:
|
|
149
|
-
raise typer.BadParameter(
|
|
150
|
-
"Remote revocation failed; local credentials were kept. "
|
|
151
|
-
"Retry, or use --local-only if the server is permanently unavailable."
|
|
152
|
-
) from exc
|
|
153
|
-
typer.echo("The remote credential was already invalid or expired.")
|
|
154
|
-
except httpx.HTTPError as exc:
|
|
155
|
-
raise typer.BadParameter(
|
|
156
|
-
"Could not reach Halios to revoke the credential; local credentials were kept. "
|
|
157
|
-
"Retry, or use --local-only if the server is permanently unavailable."
|
|
158
|
-
) from exc
|
|
159
|
-
|
|
160
|
-
delete_profile(profile)
|
|
161
|
-
suffix = " locally (remote key unchanged)" if local_only else " and revoked its API key"
|
|
162
|
-
typer.echo(f"Removed profile '{profile}'{suffix}.")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|