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.
Files changed (28) hide show
  1. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/AGENTS.md +1 -2
  2. softmax_cli-0.26.16/BUILD.bazel +3 -0
  3. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/CLAUDE.md +1 -2
  4. {softmax_cli-0.26.14/src/softmax_cli.egg-info → softmax_cli-0.26.16}/PKG-INFO +7 -6
  5. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/pyproject.toml +5 -4
  6. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/__init__.py +5 -13
  7. softmax_cli-0.26.16/src/softmax/auth.py +166 -0
  8. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/cli.py +35 -38
  9. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/perform_login.py +28 -49
  10. {softmax_cli-0.26.14 → softmax_cli-0.26.16/src/softmax_cli.egg-info}/PKG-INFO +7 -6
  11. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/SOURCES.txt +0 -2
  12. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/requires.txt +6 -5
  13. softmax_cli-0.26.16/tests/BUILD.bazel +3 -0
  14. softmax_cli-0.26.16/tests/test_auth_login.py +228 -0
  15. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/tests/test_python_api.py +16 -17
  16. softmax_cli-0.26.14/BUILD.bazel +0 -19
  17. softmax_cli-0.26.14/src/softmax/auth.py +0 -100
  18. softmax_cli-0.26.14/src/softmax/token_storage.py +0 -102
  19. softmax_cli-0.26.14/tests/BUILD.bazel +0 -39
  20. softmax_cli-0.26.14/tests/test_auth_login.py +0 -344
  21. softmax_cli-0.26.14/tests/test_cli_plugins.py +0 -23
  22. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/README.md +0 -0
  23. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/setup.cfg +0 -0
  24. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/_console.py +0 -0
  25. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax/cogames.py +0 -0
  26. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
  27. {softmax_cli-0.26.14 → softmax_cli-0.26.16}/src/softmax_cli.egg-info/entry_points.txt +0 -0
  28. {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` — auth/session state and token validation.
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
 
@@ -0,0 +1,3 @@
1
+ load("//bazel:pyproject.bzl", "pyproject_library")
2
+
3
+ pyproject_library(typecheck = False)
@@ -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` — auth/session state and token validation.
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.14
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
- Provides-Extra: cogames
17
- Requires-Dist: cogames; extra == "cogames"
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
- cogames = ["cogames"]
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 load_cogames_user_token(api_server=api_server)
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 token. Then paste the token into this window or run:\n"
33
+ "the auth code. Then paste the code into this window or run:\n"
41
34
  "\n"
42
- "softmax set-token '<TOKEN>'"
35
+ "softmax exchange-code '<CODE>'"
43
36
  ),
44
37
  open_browser=open_browser,
45
38
  )
46
39
 
47
- token = load_cogames_user_token(api_server=api_server)
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
- delete_cogames_tokens,
13
+ delete_all_tokens,
14
+ exchange_auth_code,
16
15
  fetch_cogames_whoami,
17
16
  get_api_server,
18
- load_cogames_user_token,
19
- load_current_cogames_token,
20
- save_cogames_active_token,
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 _register_optional_apps() -> None:
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 set-token --server '{server}' '<TOKEN>'"
47
- return "softmax set-token '<TOKEN>'"
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 token from the browser, then run:", style="yellow")
47
+ console.print("Copy the auth code from the browser, then run:", style="yellow")
61
48
  console.print()
62
- console.print(" ", _build_manual_set_token_command(resolved if is_custom else None))
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 the resulting auth token. "
67
- "Then run the set-token command above.",
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 load_cogames_user_token(api_server=api_server)
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 token. Then paste the token into this window or run:\n"
115
+ "the auth code. Then paste the code into this window or run:\n"
131
116
  "\n"
132
- f"{_build_manual_set_token_command(server)}"
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 delete_cogames_tokens(api_server=api_server):
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 = load_current_cogames_token(api_server=api_server)
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 = load_current_cogames_token(api_server=api_server)
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
- save_cogames_user_token(api_server=api_server, token=token)
222
+ save_user_token(server=api_server, token=token)
241
223
  print(f"\nToken saved for {api_server}")
242
224
 
243
225
 
244
- _register_optional_apps()
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, save_token
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
- try:
258
- token = request.query_params.get("token")
259
- if not token:
260
- _finish_authentication(session, error="No token received in callback")
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
- _finish_authentication(session, token=token)
264
- return HTMLResponse(content=_success_html())
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"Callback error: {exc}")
267
- return HTMLResponse(content=_error_html(error_message=f"Error: {exc}"), status_code=500)
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
- def _validate_token(*, api_server: str, token: str) -> bool | None:
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 _start_manual_token_prompt(*, session: _CLIAuthSession, api_server: str) -> None:
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
- token = input("Paste token here when ready: ").strip()
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 token:
298
+ if not code:
318
299
  continue
319
300
 
320
- validation_result = _validate_token(api_server=api_server, token=token)
321
- if validation_result is False:
322
- console.print("Invalid token. Please try again.", style="red")
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(target=_run_server, kwargs={"session": session, "port": port}, daemon=True).start()
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
- _start_manual_token_prompt(session=session, api_server=api_server)
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
- token_server = save_token_under or api_server
394
- save_token(token_kind=token_kind, server=token_server, token=session.token)
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.14
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
- Provides-Extra: cogames
17
- Requires-Dist: cogames; extra == "cogames"
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