softmax-cli 0.26.14__tar.gz → 0.26.16__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.
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/AGENTS.md +1 -2
- softmax_cli-0.26.16/BUILD.bazel +3 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/CLAUDE.md +1 -2
- {softmax_cli-0.26.14/src/softmax_cli.egg-info → softmax_cli-0.26.16}/PKG-INFO +7 -6
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/pyproject.toml +5 -4
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/__init__.py +5 -13
- softmax_cli-0.26.16/src/softmax/auth.py +166 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/cli.py +35 -38
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/perform_login.py +28 -49
- {softmax_cli-0.26.14 → softmax_cli-0.26.16/src/softmax_cli.egg-info}/PKG-INFO +7 -6
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/SOURCES.txt +0 -2
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/requires.txt +6 -5
- softmax_cli-0.26.16/tests/BUILD.bazel +3 -0
- softmax_cli-0.26.16/tests/test_auth_login.py +228 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/tests/test_python_api.py +16 -17
- softmax_cli-0.26.14/BUILD.bazel +0 -19
- softmax_cli-0.26.14/src/softmax/auth.py +0 -100
- softmax_cli-0.26.14/src/softmax/token_storage.py +0 -102
- softmax_cli-0.26.14/tests/BUILD.bazel +0 -39
- softmax_cli-0.26.14/tests/test_auth_login.py +0 -344
- softmax_cli-0.26.14/tests/test_cli_plugins.py +0 -23
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/README.md +0 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/setup.cfg +0 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/_console.py +0 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/cogames.py +0 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/entry_points.txt +0 -0
- {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/top_level.txt +0 -0
|
@@ -36,9 +36,8 @@ uv run metta lint --fix # ruff (also runs via the Edit/Write hook)
|
|
|
36
36
|
## Source layout (`src/softmax/`)
|
|
37
37
|
|
|
38
38
|
- `cli.py` — the Typer app; mounts the optional `cogames` subapp via `add_typer`.
|
|
39
|
-
- `auth.py` —
|
|
39
|
+
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
40
40
|
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
41
|
-
- `token_storage.py` — on-disk token persistence.
|
|
42
41
|
- `cogames.py` — the optional cogames subcommand wiring.
|
|
43
42
|
- `_console.py` — shared rich console helpers.
|
|
44
43
|
|
|
@@ -36,9 +36,8 @@ uv run metta lint --fix # ruff (also runs via the Edit/Write hook)
|
|
|
36
36
|
## Source layout (`src/softmax/`)
|
|
37
37
|
|
|
38
38
|
- `cli.py` — the Typer app; mounts the optional `cogames` subapp via `add_typer`.
|
|
39
|
-
- `auth.py` —
|
|
39
|
+
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
40
40
|
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
41
|
-
- `token_storage.py` — on-disk token persistence.
|
|
42
41
|
- `cogames.py` — the optional cogames subcommand wiring.
|
|
43
42
|
- `_console.py` — shared rich console helpers.
|
|
44
43
|
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softmax-cli
|
|
3
|
-
Version: 0.26.
|
|
3
|
+
Version: 0.26.16
|
|
4
4
|
Summary: Softmax CLI — authentication and account tools
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
7
7
|
Classifier: Programming Language :: Python :: 3.12
|
|
8
8
|
Requires-Python: <3.13,>=3.11
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
|
-
Requires-Dist: typer>=0.19.2
|
|
11
|
-
Requires-Dist: rich>=13.7.0
|
|
12
10
|
Requires-Dist: fastapi>=0.115.0
|
|
13
|
-
Requires-Dist: uvicorn>=0.34.0
|
|
14
11
|
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: pydantic>=2.0
|
|
15
13
|
Requires-Dist: pyyaml>=6.0.2
|
|
16
|
-
|
|
17
|
-
Requires-Dist:
|
|
14
|
+
Requires-Dist: rich>=13.7.0
|
|
15
|
+
Requires-Dist: typer>=0.19.2
|
|
16
|
+
Requires-Dist: uvicorn>=0.34.0
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
18
19
|
|
|
19
20
|
# softmax-cli
|
|
20
21
|
|
|
@@ -13,17 +13,18 @@ classifiers = [
|
|
|
13
13
|
"Programming Language :: Python :: 3.12",
|
|
14
14
|
]
|
|
15
15
|
dependencies = [
|
|
16
|
-
"typer>=0.19.2",
|
|
17
|
-
"rich>=13.7.0",
|
|
18
16
|
"fastapi>=0.115.0",
|
|
19
|
-
"uvicorn>=0.34.0",
|
|
20
17
|
"httpx>=0.28.1",
|
|
18
|
+
"pydantic>=2.0",
|
|
21
19
|
"pyyaml>=6.0.2",
|
|
20
|
+
"rich>=13.7.0",
|
|
21
|
+
"typer>=0.19.2",
|
|
22
|
+
"uvicorn>=0.34.0",
|
|
22
23
|
]
|
|
23
24
|
dynamic = ["version"]
|
|
24
25
|
|
|
25
26
|
[project.optional-dependencies]
|
|
26
|
-
|
|
27
|
+
test = ["pytest>=8.0"]
|
|
27
28
|
|
|
28
29
|
[project.scripts]
|
|
29
30
|
softmax = "softmax.cli:app"
|
|
@@ -4,13 +4,8 @@ import importlib
|
|
|
4
4
|
import sys
|
|
5
5
|
from pkgutil import extend_path
|
|
6
6
|
|
|
7
|
-
from softmax.auth import
|
|
8
|
-
DEFAULT_API_SERVER,
|
|
9
|
-
load_cogames_user_token,
|
|
10
|
-
save_cogames_active_token,
|
|
11
|
-
)
|
|
7
|
+
from softmax.auth import DEFAULT_API_SERVER, load_user_token
|
|
12
8
|
from softmax.perform_login import do_interactive_login_for_token
|
|
13
|
-
from softmax.token_storage import TokenKind
|
|
14
9
|
|
|
15
10
|
__path__ = extend_path(__path__, __name__)
|
|
16
11
|
|
|
@@ -21,9 +16,8 @@ def login(
|
|
|
21
16
|
force: bool = False,
|
|
22
17
|
open_browser: bool = True,
|
|
23
18
|
) -> str:
|
|
24
|
-
token = None if force else
|
|
19
|
+
token = None if force else load_user_token(server=api_server)
|
|
25
20
|
if token is not None:
|
|
26
|
-
save_cogames_active_token(api_server=api_server, token=token)
|
|
27
21
|
return token
|
|
28
22
|
|
|
29
23
|
if not sys.stdin.isatty():
|
|
@@ -34,20 +28,18 @@ def login(
|
|
|
34
28
|
|
|
35
29
|
do_interactive_login_for_token(
|
|
36
30
|
api_server=api_server,
|
|
37
|
-
token_kind=TokenKind.COGAMES_USER,
|
|
38
31
|
agent_hint=(
|
|
39
32
|
"If you are a coding agent, ask your human to open the URL below and give you "
|
|
40
|
-
"the auth
|
|
33
|
+
"the auth code. Then paste the code into this window or run:\n"
|
|
41
34
|
"\n"
|
|
42
|
-
"softmax
|
|
35
|
+
"softmax exchange-code '<CODE>'"
|
|
43
36
|
),
|
|
44
37
|
open_browser=open_browser,
|
|
45
38
|
)
|
|
46
39
|
|
|
47
|
-
token =
|
|
40
|
+
token = load_user_token(server=api_server)
|
|
48
41
|
if token is None:
|
|
49
42
|
raise RuntimeError(f"Interactive login did not save a token for {api_server}")
|
|
50
|
-
save_cogames_active_token(api_server=api_server, token=token)
|
|
51
43
|
return token
|
|
52
44
|
|
|
53
45
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Auth helpers for the softmax CLI: token storage, browser login URL, whoami."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_SERVER = "https://softmax.com/api"
|
|
14
|
+
|
|
15
|
+
CREDENTIALS_FILE = "credentials.yaml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_api_server() -> str:
|
|
19
|
+
return os.environ.get("COGAMES_API_URL", DEFAULT_API_SERVER)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- on-disk storage ---
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _config_dir() -> Path:
|
|
26
|
+
config_dir = Path.home() / ".softmax"
|
|
27
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
return config_dir
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _credentials_path() -> Path:
|
|
32
|
+
return _config_dir() / CREDENTIALS_FILE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_data() -> dict:
|
|
36
|
+
cred_path = _credentials_path()
|
|
37
|
+
if not cred_path.exists():
|
|
38
|
+
return {"tokens": {}, "player_sessions": {}}
|
|
39
|
+
|
|
40
|
+
with open(cred_path, "r") as f:
|
|
41
|
+
return yaml.safe_load(f) or {"tokens": {}, "player_sessions": {}}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save_data(data: dict) -> None:
|
|
45
|
+
cred_path = _credentials_path()
|
|
46
|
+
with open(cred_path, "w") as f:
|
|
47
|
+
yaml.safe_dump(data, f, default_flow_style=False)
|
|
48
|
+
os.chmod(cred_path, 0o600)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- public token API ---
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_user_token(*, server: str) -> str | None:
|
|
55
|
+
server = server.rstrip("/")
|
|
56
|
+
tokens = _load_data().get("tokens", {})
|
|
57
|
+
token = tokens.get(server)
|
|
58
|
+
return token if isinstance(token, str) else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_user_token(*, server: str, token: str) -> None:
|
|
62
|
+
server = server.rstrip("/")
|
|
63
|
+
data = _load_data()
|
|
64
|
+
data.setdefault("tokens", {})[server] = token
|
|
65
|
+
_save_data(data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _delete_user_token(*, server: str) -> bool:
|
|
69
|
+
server = server.rstrip("/")
|
|
70
|
+
data = _load_data()
|
|
71
|
+
tokens = data.get("tokens", {})
|
|
72
|
+
if server not in tokens:
|
|
73
|
+
return False
|
|
74
|
+
del tokens[server]
|
|
75
|
+
_save_data(data)
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_player_session(*, server: str) -> str | None:
|
|
80
|
+
server = server.rstrip("/")
|
|
81
|
+
sessions = _load_data().get("player_sessions", {})
|
|
82
|
+
token = sessions.get(server)
|
|
83
|
+
return token if isinstance(token, str) else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_player_session(*, server: str, token: str) -> None:
|
|
87
|
+
server = server.rstrip("/")
|
|
88
|
+
data = _load_data()
|
|
89
|
+
data.setdefault("player_sessions", {})[server] = token
|
|
90
|
+
_save_data(data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _delete_player_session(*, server: str) -> bool:
|
|
94
|
+
server = server.rstrip("/")
|
|
95
|
+
data = _load_data()
|
|
96
|
+
sessions = data.get("player_sessions", {})
|
|
97
|
+
if server not in sessions:
|
|
98
|
+
return False
|
|
99
|
+
del sessions[server]
|
|
100
|
+
_save_data(data)
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_current_token(*, server: str) -> str | None:
|
|
105
|
+
"""Returns player_session if present, else user token."""
|
|
106
|
+
return load_player_session(server=server) or load_user_token(server=server)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def has_saved_token(*, server: str) -> bool:
|
|
110
|
+
return load_current_token(server=server) is not None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def delete_all_tokens(*, server: str) -> bool:
|
|
114
|
+
deleted_token = _delete_user_token(server=server)
|
|
115
|
+
deleted_session = _delete_player_session(server=server)
|
|
116
|
+
return deleted_token or deleted_session
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- HTTP / URL helpers ---
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class WhoAmIResponse(BaseModel):
|
|
123
|
+
user_email: str
|
|
124
|
+
is_softmax_team_member: bool = False
|
|
125
|
+
is_softmax_admin: bool = False
|
|
126
|
+
subject_type: str = "user"
|
|
127
|
+
subject_id: str | None = None
|
|
128
|
+
owner_user_id: str | None = None
|
|
129
|
+
scopes: list[str] = Field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_browser_login_url(api_server: str, *, callback_url: str | None = None) -> str:
|
|
133
|
+
"""Build the hosted browser sign-in URL for CLI login."""
|
|
134
|
+
params: dict[str, str] = {}
|
|
135
|
+
if callback_url:
|
|
136
|
+
params["callback"] = callback_url
|
|
137
|
+
|
|
138
|
+
query = urlencode(params)
|
|
139
|
+
parsed = urlsplit(api_server)
|
|
140
|
+
# Code-exchange flow lives at /cli-auth; the legacy /cli-login route is
|
|
141
|
+
# preserved untouched for published softmax-cli <= 0.26.14 (pinned by
|
|
142
|
+
# coworld[auth]) until those releases age out.
|
|
143
|
+
browser_path = parsed.path.rstrip("/").removesuffix("/api") + "/cli-auth"
|
|
144
|
+
return urlunsplit((parsed.scheme, parsed.netloc, browser_path, query, ""))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def fetch_cogames_whoami(*, api_server: str | None = None, token: str) -> WhoAmIResponse:
|
|
148
|
+
server = api_server or DEFAULT_API_SERVER
|
|
149
|
+
response = httpx.get(
|
|
150
|
+
f"{server.rstrip('/')}/observatory/whoami",
|
|
151
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
152
|
+
timeout=10.0,
|
|
153
|
+
)
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
return WhoAmIResponse.model_validate(response.json())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def exchange_auth_code(*, api_server: str, code: str) -> str:
|
|
159
|
+
"""Exchange a one-time auth code for a real credential token."""
|
|
160
|
+
response = httpx.post(
|
|
161
|
+
f"{api_server.rstrip('/')}/observatory/credentials/auth-codes/exchange",
|
|
162
|
+
json={"code": code},
|
|
163
|
+
timeout=10.0,
|
|
164
|
+
)
|
|
165
|
+
response.raise_for_status()
|
|
166
|
+
return response.json()["token"]
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""softmax CLI — authentication and account tools."""
|
|
2
2
|
|
|
3
|
-
import importlib
|
|
4
|
-
import importlib.util
|
|
5
3
|
import sys
|
|
6
4
|
|
|
7
5
|
import httpx
|
|
@@ -12,16 +10,15 @@ from softmax._console import console
|
|
|
12
10
|
from softmax.auth import (
|
|
13
11
|
DEFAULT_API_SERVER,
|
|
14
12
|
build_browser_login_url,
|
|
15
|
-
|
|
13
|
+
delete_all_tokens,
|
|
14
|
+
exchange_auth_code,
|
|
16
15
|
fetch_cogames_whoami,
|
|
17
16
|
get_api_server,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
save_cogames_user_token,
|
|
17
|
+
load_current_token,
|
|
18
|
+
load_user_token,
|
|
19
|
+
save_user_token,
|
|
22
20
|
)
|
|
23
21
|
from softmax.perform_login import do_interactive_login_for_token
|
|
24
|
-
from softmax.token_storage import TokenKind
|
|
25
22
|
|
|
26
23
|
app = typer.Typer(
|
|
27
24
|
help="Softmax CLI — authentication and account tools",
|
|
@@ -31,20 +28,10 @@ app = typer.Typer(
|
|
|
31
28
|
)
|
|
32
29
|
|
|
33
30
|
|
|
34
|
-
def
|
|
35
|
-
if importlib.util.find_spec("cogames") is None:
|
|
36
|
-
return
|
|
37
|
-
if importlib.util.find_spec("cogames.softmax_cli") is None:
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
cogames_cli = importlib.import_module("cogames.softmax_cli")
|
|
41
|
-
app.add_typer(cogames_cli.app, name="cogames", rich_help_panel="Local Games")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _build_manual_set_token_command(server: str | None = None) -> str:
|
|
31
|
+
def _build_manual_exchange_command(server: str | None = None) -> str:
|
|
45
32
|
if server:
|
|
46
|
-
return f"softmax
|
|
47
|
-
return "softmax
|
|
33
|
+
return f"softmax exchange-code --server '{server}' '<CODE>'"
|
|
34
|
+
return "softmax exchange-code '<CODE>'"
|
|
48
35
|
|
|
49
36
|
|
|
50
37
|
def _print_non_tty_login_instructions(api_server: str | None = None) -> None:
|
|
@@ -57,14 +44,14 @@ def _print_non_tty_login_instructions(api_server: str | None = None) -> None:
|
|
|
57
44
|
console.print()
|
|
58
45
|
console.print(" ", auth_url)
|
|
59
46
|
console.print()
|
|
60
|
-
console.print("Copy the auth
|
|
47
|
+
console.print("Copy the auth code from the browser, then run:", style="yellow")
|
|
61
48
|
console.print()
|
|
62
|
-
console.print(" ",
|
|
49
|
+
console.print(" ", _build_manual_exchange_command(resolved if is_custom else None))
|
|
63
50
|
console.print()
|
|
64
51
|
console.print(
|
|
65
52
|
Panel(
|
|
66
|
-
"If you are a coding agent, ask your human to open the URL above and give you
|
|
67
|
-
"Then run the
|
|
53
|
+
"If you are a coding agent, ask your human to open the URL above and give you "
|
|
54
|
+
"the auth code. Then run the exchange-code command above.",
|
|
68
55
|
title="🤖 Agent Hint",
|
|
69
56
|
border_style="cyan",
|
|
70
57
|
)
|
|
@@ -96,7 +83,7 @@ def login_cmd(
|
|
|
96
83
|
from urllib.parse import urlparse # noqa: PLC0415
|
|
97
84
|
|
|
98
85
|
api_server = server or get_api_server()
|
|
99
|
-
user_token = None if force else
|
|
86
|
+
user_token = None if force else load_user_token(server=api_server)
|
|
100
87
|
if user_token is not None:
|
|
101
88
|
try:
|
|
102
89
|
whoami = fetch_cogames_whoami(api_server=api_server, token=user_token)
|
|
@@ -113,7 +100,6 @@ def login_cmd(
|
|
|
113
100
|
console.print("Could not reach server to verify token. Proceeding with saved token.", style="yellow")
|
|
114
101
|
|
|
115
102
|
if user_token is not None:
|
|
116
|
-
save_cogames_active_token(api_server=api_server, token=user_token)
|
|
117
103
|
console.print(f"Already authenticated with {urlparse(api_server).hostname}", style="green")
|
|
118
104
|
return
|
|
119
105
|
|
|
@@ -124,12 +110,11 @@ def login_cmd(
|
|
|
124
110
|
try:
|
|
125
111
|
do_interactive_login_for_token(
|
|
126
112
|
api_server=api_server,
|
|
127
|
-
token_kind=TokenKind.COGAMES_USER,
|
|
128
113
|
agent_hint=(
|
|
129
114
|
"If you are a coding agent, ask your human to open the URL below and give you "
|
|
130
|
-
"the auth
|
|
115
|
+
"the auth code. Then paste the code into this window or run:\n"
|
|
131
116
|
"\n"
|
|
132
|
-
f"{
|
|
117
|
+
f"{_build_manual_exchange_command(server)}"
|
|
133
118
|
),
|
|
134
119
|
open_browser=not no_browser,
|
|
135
120
|
)
|
|
@@ -139,9 +124,6 @@ def login_cmd(
|
|
|
139
124
|
console.print("Authentication failed.", style="red")
|
|
140
125
|
raise typer.Exit(1) from e
|
|
141
126
|
|
|
142
|
-
user_token = load_cogames_user_token(api_server=api_server)
|
|
143
|
-
assert user_token is not None
|
|
144
|
-
save_cogames_active_token(api_server=api_server, token=user_token)
|
|
145
127
|
console.print("Authentication successful.", style="green")
|
|
146
128
|
|
|
147
129
|
|
|
@@ -157,7 +139,7 @@ def logout_cmd(
|
|
|
157
139
|
) -> None:
|
|
158
140
|
"""Remove saved authentication token."""
|
|
159
141
|
api_server = server or get_api_server()
|
|
160
|
-
if
|
|
142
|
+
if delete_all_tokens(server=api_server):
|
|
161
143
|
console.print("Logged out.", style="green")
|
|
162
144
|
else:
|
|
163
145
|
console.print("No token found — already logged out.", style="yellow")
|
|
@@ -189,7 +171,7 @@ def status_cmd(
|
|
|
189
171
|
) -> None:
|
|
190
172
|
"""Check authentication status via /whoami."""
|
|
191
173
|
api_server = server or get_api_server()
|
|
192
|
-
token =
|
|
174
|
+
token = load_current_token(server=api_server)
|
|
193
175
|
if not token:
|
|
194
176
|
console.print("[red]Not authenticated.[/red] Run [cyan]softmax login[/cyan] first.")
|
|
195
177
|
raise typer.Exit(1)
|
|
@@ -217,7 +199,7 @@ def get_token_cmd(
|
|
|
217
199
|
) -> None:
|
|
218
200
|
"""Print the saved token to stdout (for scripting)."""
|
|
219
201
|
api_server = server or get_api_server()
|
|
220
|
-
token =
|
|
202
|
+
token = load_current_token(server=api_server)
|
|
221
203
|
if not token:
|
|
222
204
|
console.print("[red]No token found.[/red] Run [cyan]softmax login[/cyan] first.", style="bold")
|
|
223
205
|
raise typer.Exit(1)
|
|
@@ -237,8 +219,23 @@ def set_token_cmd(
|
|
|
237
219
|
) -> None:
|
|
238
220
|
"""Manually set a token (for CI or headless environments)."""
|
|
239
221
|
api_server = server or get_api_server()
|
|
240
|
-
|
|
222
|
+
save_user_token(server=api_server, token=token)
|
|
241
223
|
print(f"\nToken saved for {api_server}")
|
|
242
224
|
|
|
243
225
|
|
|
244
|
-
|
|
226
|
+
@app.command(name="exchange-code")
|
|
227
|
+
def exchange_code_cmd(
|
|
228
|
+
code: str = typer.Argument(help="One-time auth code from browser login"),
|
|
229
|
+
server: str | None = typer.Option(
|
|
230
|
+
None,
|
|
231
|
+
"--server",
|
|
232
|
+
"-s",
|
|
233
|
+
metavar="URL",
|
|
234
|
+
help="API server URL.",
|
|
235
|
+
),
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Exchange a one-time auth code for a credential."""
|
|
238
|
+
api_server = server or get_api_server()
|
|
239
|
+
token = exchange_auth_code(api_server=api_server, code=code)
|
|
240
|
+
save_user_token(server=api_server, token=token)
|
|
241
|
+
print(f"\nToken saved for {api_server}")
|
|
@@ -13,7 +13,6 @@ from dataclasses import dataclass, field
|
|
|
13
13
|
from datetime import datetime
|
|
14
14
|
from typing import Literal, Sequence
|
|
15
15
|
|
|
16
|
-
import httpx
|
|
17
16
|
import uvicorn
|
|
18
17
|
from fastapi import FastAPI, Request
|
|
19
18
|
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -21,8 +20,7 @@ from fastapi.responses import HTMLResponse
|
|
|
21
20
|
from rich.panel import Panel
|
|
22
21
|
|
|
23
22
|
from softmax._console import console
|
|
24
|
-
from softmax.auth import build_browser_login_url,
|
|
25
|
-
from softmax.token_storage import TokenKind
|
|
23
|
+
from softmax.auth import build_browser_login_url, exchange_auth_code, save_user_token
|
|
26
24
|
|
|
27
25
|
|
|
28
26
|
@dataclass
|
|
@@ -242,7 +240,7 @@ def _finish_authentication(session: _CLIAuthSession, *, token: str | None = None
|
|
|
242
240
|
return True
|
|
243
241
|
|
|
244
242
|
|
|
245
|
-
def _create_app(session: _CLIAuthSession) -> FastAPI:
|
|
243
|
+
def _create_app(session: _CLIAuthSession, *, api_server: str) -> FastAPI:
|
|
246
244
|
app = FastAPI(title="CLI OAuth2 Callback Server")
|
|
247
245
|
app.add_middleware(
|
|
248
246
|
CORSMiddleware,
|
|
@@ -254,38 +252,21 @@ def _create_app(session: _CLIAuthSession) -> FastAPI:
|
|
|
254
252
|
|
|
255
253
|
@app.get("/callback")
|
|
256
254
|
async def callback(request: Request) -> HTMLResponse:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return HTMLResponse(content=_error_html(error_message="No token received"), status_code=400)
|
|
255
|
+
code = request.query_params.get("code")
|
|
256
|
+
if not code:
|
|
257
|
+
_finish_authentication(session, error="No code received in callback")
|
|
258
|
+
return HTMLResponse(content=_error_html(error_message="No code received"), status_code=400)
|
|
262
259
|
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
try:
|
|
261
|
+
token = exchange_auth_code(api_server=api_server, code=code)
|
|
265
262
|
except Exception as exc:
|
|
266
|
-
_finish_authentication(session, error=f"
|
|
267
|
-
return HTMLResponse(content=_error_html(error_message=f"
|
|
268
|
-
|
|
269
|
-
return app
|
|
263
|
+
_finish_authentication(session, error=f"Code exchange failed: {exc}")
|
|
264
|
+
return HTMLResponse(content=_error_html(error_message=f"Code exchange failed: {exc}"), status_code=500)
|
|
270
265
|
|
|
266
|
+
_finish_authentication(session, token=token)
|
|
267
|
+
return HTMLResponse(content=_success_html())
|
|
271
268
|
|
|
272
|
-
|
|
273
|
-
whoami_url = f"{api_server.rstrip('/')}/observatory/whoami"
|
|
274
|
-
try:
|
|
275
|
-
response = httpx.get(
|
|
276
|
-
whoami_url,
|
|
277
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
278
|
-
timeout=5.0,
|
|
279
|
-
)
|
|
280
|
-
except httpx.HTTPError:
|
|
281
|
-
return None
|
|
282
|
-
|
|
283
|
-
if response.status_code == 200:
|
|
284
|
-
data = response.json()
|
|
285
|
-
return data.get("subject_type") != "anonymous"
|
|
286
|
-
if response.status_code in {400, 401, 403}:
|
|
287
|
-
return False
|
|
288
|
-
return None
|
|
269
|
+
return app
|
|
289
270
|
|
|
290
271
|
|
|
291
272
|
def _print_login_instructions(*, auth_url: str, agent_hint: str | None) -> None:
|
|
@@ -304,25 +285,24 @@ def _print_login_instructions(*, auth_url: str, agent_hint: str | None) -> None:
|
|
|
304
285
|
console.print()
|
|
305
286
|
|
|
306
287
|
|
|
307
|
-
def
|
|
288
|
+
def _start_manual_code_prompt(*, session: _CLIAuthSession, api_server: str) -> None:
|
|
308
289
|
def prompt_loop() -> None:
|
|
309
290
|
while not session.auth_completed.is_set():
|
|
310
291
|
try:
|
|
311
|
-
|
|
292
|
+
code = input("Paste auth code here when ready: ").strip()
|
|
312
293
|
except EOFError:
|
|
313
294
|
return
|
|
314
295
|
|
|
315
296
|
if session.auth_completed.is_set():
|
|
316
297
|
return
|
|
317
|
-
if not
|
|
298
|
+
if not code:
|
|
318
299
|
continue
|
|
319
300
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
301
|
+
try:
|
|
302
|
+
token = exchange_auth_code(api_server=api_server, code=code)
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
console.print(f"Code exchange failed: {exc}", style="red")
|
|
323
305
|
continue
|
|
324
|
-
if validation_result is None:
|
|
325
|
-
console.print("Could not validate token right now. Saving it anyway.", style="yellow")
|
|
326
306
|
|
|
327
307
|
_finish_authentication(session, token=token)
|
|
328
308
|
return
|
|
@@ -330,9 +310,9 @@ def _start_manual_token_prompt(*, session: _CLIAuthSession, api_server: str) ->
|
|
|
330
310
|
threading.Thread(target=prompt_loop, daemon=True).start()
|
|
331
311
|
|
|
332
312
|
|
|
333
|
-
def _run_server(*, session: _CLIAuthSession, port: int) -> None:
|
|
313
|
+
def _run_server(*, session: _CLIAuthSession, port: int, api_server: str) -> None:
|
|
334
314
|
try:
|
|
335
|
-
app = _create_app(session)
|
|
315
|
+
app = _create_app(session, api_server=api_server)
|
|
336
316
|
config = uvicorn.Config(
|
|
337
317
|
app=app,
|
|
338
318
|
host="127.0.0.1",
|
|
@@ -361,10 +341,8 @@ def _wait_for_callback_server_to_start(*, session: _CLIAuthSession, port: int, t
|
|
|
361
341
|
def do_interactive_login_for_token(
|
|
362
342
|
*,
|
|
363
343
|
api_server: str,
|
|
364
|
-
token_kind: TokenKind,
|
|
365
344
|
agent_hint: str | None,
|
|
366
345
|
open_browser: bool,
|
|
367
|
-
save_token_under: str | None = None,
|
|
368
346
|
) -> None:
|
|
369
347
|
"""Run the CLI browser login flow and save the resulting token."""
|
|
370
348
|
assert sys.stdin.isatty(), "This function should only be called when stdin is a TTY"
|
|
@@ -373,7 +351,9 @@ def do_interactive_login_for_token(
|
|
|
373
351
|
callback_url: str | None = None
|
|
374
352
|
port = _find_free_port()
|
|
375
353
|
|
|
376
|
-
threading.Thread(
|
|
354
|
+
threading.Thread(
|
|
355
|
+
target=_run_server, kwargs={"session": session, "port": port, "api_server": api_server}, daemon=True
|
|
356
|
+
).start()
|
|
377
357
|
if _wait_for_callback_server_to_start(session=session, port=port):
|
|
378
358
|
callback_url = f"http://127.0.0.1:{port}/callback"
|
|
379
359
|
session.error = None
|
|
@@ -383,14 +363,13 @@ def do_interactive_login_for_token(
|
|
|
383
363
|
if open_browser:
|
|
384
364
|
_open_browser(url=auth_url)
|
|
385
365
|
|
|
386
|
-
|
|
366
|
+
_start_manual_code_prompt(session=session, api_server=api_server)
|
|
387
367
|
session.auth_completed.wait()
|
|
388
368
|
if session.error:
|
|
389
369
|
raise RuntimeError(session.error)
|
|
390
370
|
if not session.token:
|
|
391
371
|
raise RuntimeError("No token received")
|
|
392
372
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
print(f"\nToken saved for {token_server}")
|
|
373
|
+
save_user_token(server=api_server, token=session.token)
|
|
374
|
+
print(f"\nToken saved for {api_server}")
|
|
396
375
|
print()
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softmax-cli
|
|
3
|
-
Version: 0.26.
|
|
3
|
+
Version: 0.26.16
|
|
4
4
|
Summary: Softmax CLI — authentication and account tools
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
7
7
|
Classifier: Programming Language :: Python :: 3.12
|
|
8
8
|
Requires-Python: <3.13,>=3.11
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
|
-
Requires-Dist: typer>=0.19.2
|
|
11
|
-
Requires-Dist: rich>=13.7.0
|
|
12
10
|
Requires-Dist: fastapi>=0.115.0
|
|
13
|
-
Requires-Dist: uvicorn>=0.34.0
|
|
14
11
|
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: pydantic>=2.0
|
|
15
13
|
Requires-Dist: pyyaml>=6.0.2
|
|
16
|
-
|
|
17
|
-
Requires-Dist:
|
|
14
|
+
Requires-Dist: rich>=13.7.0
|
|
15
|
+
Requires-Dist: typer>=0.19.2
|
|
16
|
+
Requires-Dist: uvicorn>=0.34.0
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
18
19
|
|
|
19
20
|
# softmax-cli
|
|
20
21
|
|
|
@@ -9,7 +9,6 @@ src/softmax/auth.py
|
|
|
9
9
|
src/softmax/cli.py
|
|
10
10
|
src/softmax/cogames.py
|
|
11
11
|
src/softmax/perform_login.py
|
|
12
|
-
src/softmax/token_storage.py
|
|
13
12
|
src/softmax_cli.egg-info/PKG-INFO
|
|
14
13
|
src/softmax_cli.egg-info/SOURCES.txt
|
|
15
14
|
src/softmax_cli.egg-info/dependency_links.txt
|
|
@@ -18,5 +17,4 @@ src/softmax_cli.egg-info/requires.txt
|
|
|
18
17
|
src/softmax_cli.egg-info/top_level.txt
|
|
19
18
|
tests/BUILD.bazel
|
|
20
19
|
tests/test_auth_login.py
|
|
21
|
-
tests/test_cli_plugins.py
|
|
22
20
|
tests/test_python_api.py
|