softmax-cli 0.26.18__tar.gz → 0.26.20__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.20/AGENTS.md +61 -0
- softmax_cli-0.26.20/CLAUDE.md +61 -0
- {softmax_cli-0.26.18/src/softmax_cli.egg-info → softmax_cli-0.26.20}/PKG-INFO +8 -7
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/README.md +6 -6
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/pyproject.toml +1 -1
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/__init__.py +1 -8
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/auth.py +86 -15
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/cli.py +13 -0
- softmax_cli-0.26.20/src/softmax/players.py +138 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20/src/softmax_cli.egg-info}/PKG-INFO +8 -7
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/SOURCES.txt +2 -1
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/requires.txt +1 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/tests/test_auth_login.py +12 -2
- softmax_cli-0.26.20/tests/test_player_cli.py +179 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/tests/test_python_api.py +89 -88
- softmax_cli-0.26.18/AGENTS.md +0 -48
- softmax_cli-0.26.18/CLAUDE.md +0 -48
- softmax_cli-0.26.18/src/softmax/cogames.py +0 -58
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/BUILD.bazel +0 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/setup.cfg +0 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/_console.py +0 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/perform_login.py +0 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/entry_points.txt +0 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/top_level.txt +0 -0
- {softmax_cli-0.26.18 → softmax_cli-0.26.20}/tests/BUILD.bazel +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# AGENTS.md — softmax-cli
|
|
2
|
+
|
|
3
|
+
The `softmax` CLI: authentication and account tools for Softmax/Observatory. Other packages (e.g. `coworld`) depend on
|
|
4
|
+
it for login/token handling and pin an exact version. Deps: `typer`, `rich`, `httpx`, plus `fastapi`/`uvicorn` for the
|
|
5
|
+
local browser-login callback server. Published to PyPI.
|
|
6
|
+
|
|
7
|
+
## CLI
|
|
8
|
+
|
|
9
|
+
Installs a `softmax` entrypoint (`softmax.cli:app`, Typer):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv run softmax login # browser-based login (spins up a local callback server)
|
|
13
|
+
uv run softmax logout
|
|
14
|
+
uv run softmax status
|
|
15
|
+
uv run softmax get-login-url
|
|
16
|
+
uv run softmax get-token / set-token
|
|
17
|
+
uv run softmax player list / use <player-id> / unset
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`softmax player use <player-id>` mints (or reuses) a 24h player session and stores it as the active player in
|
|
21
|
+
`~/.softmax/credentials.yaml` (`player_sessions`). Every auth-backed command in any CLI built on softmax-cli
|
|
22
|
+
(including `coworld`, which mounts this subapp as `coworld player`) then acts as that player, because they all
|
|
23
|
+
resolve their token through `softmax.auth.load_current_token`. `softmax player unset` clears the active pointer,
|
|
24
|
+
reverting to your main user credential. `player list`/`use` themselves authenticate with the user token (player
|
|
25
|
+
routes reject player-scoped tokens).
|
|
26
|
+
|
|
27
|
+
## Tests
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv run metta pytest packages/softmax-cli/tests -v
|
|
31
|
+
uv run metta pytest --changed
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Tests cover auth/login, the Python API, player identity switching, and CLI plugin wiring; a `BUILD.bazel` exists under
|
|
35
|
+
`tests/`.
|
|
36
|
+
|
|
37
|
+
## Lint
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv run metta lint --fix # ruff (also runs via the Edit/Write hook)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Source layout (`src/softmax/`)
|
|
44
|
+
|
|
45
|
+
- `cli.py` — the Typer app; mounts the `player` subapp via `add_typer`.
|
|
46
|
+
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
47
|
+
- `players.py` — player API calls (`/observatory/players*`) and the `player list/use/unset` subapp; `coworld`
|
|
48
|
+
mounts this same subapp.
|
|
49
|
+
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
50
|
+
- `_console.py` — shared rich console helpers.
|
|
51
|
+
|
|
52
|
+
## Gotchas
|
|
53
|
+
|
|
54
|
+
- Versioned via `setuptools_scm` off `softmax-v*` git tags (`fallback_version = 0.0.0`).
|
|
55
|
+
- Downstream packages pin an exact `softmax-cli==X.Y.Z`; bumping the public auth API can break them — coordinate version
|
|
56
|
+
bumps with consumers like `coworld`.
|
|
57
|
+
- `player_sessions[server]` in `credentials.yaml` is a structured object (`active` pointer + per-player `cache` of
|
|
58
|
+
`{token, expires_at}`), not a flat token string. Use the typed helpers in `auth.py`
|
|
59
|
+
(`set_active_player_session`, `clear_active_player_session`, `get_active_player_id`, `get_cached_player_session`,
|
|
60
|
+
`load_player_session`); `load_current_token` returns the active player token when one is selected, else the user
|
|
61
|
+
token. `softmax player use/unset` drives this.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# AGENTS.md — softmax-cli
|
|
2
|
+
|
|
3
|
+
The `softmax` CLI: authentication and account tools for Softmax/Observatory. Other packages (e.g. `coworld`) depend on
|
|
4
|
+
it for login/token handling and pin an exact version. Deps: `typer`, `rich`, `httpx`, plus `fastapi`/`uvicorn` for the
|
|
5
|
+
local browser-login callback server. Published to PyPI.
|
|
6
|
+
|
|
7
|
+
## CLI
|
|
8
|
+
|
|
9
|
+
Installs a `softmax` entrypoint (`softmax.cli:app`, Typer):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv run softmax login # browser-based login (spins up a local callback server)
|
|
13
|
+
uv run softmax logout
|
|
14
|
+
uv run softmax status
|
|
15
|
+
uv run softmax get-login-url
|
|
16
|
+
uv run softmax get-token / set-token
|
|
17
|
+
uv run softmax player list / use <player-id> / unset
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`softmax player use <player-id>` mints (or reuses) a 24h player session and stores it as the active player in
|
|
21
|
+
`~/.softmax/credentials.yaml` (`player_sessions`). Every auth-backed command in any CLI built on softmax-cli
|
|
22
|
+
(including `coworld`, which mounts this subapp as `coworld player`) then acts as that player, because they all
|
|
23
|
+
resolve their token through `softmax.auth.load_current_token`. `softmax player unset` clears the active pointer,
|
|
24
|
+
reverting to your main user credential. `player list`/`use` themselves authenticate with the user token (player
|
|
25
|
+
routes reject player-scoped tokens).
|
|
26
|
+
|
|
27
|
+
## Tests
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv run metta pytest packages/softmax-cli/tests -v
|
|
31
|
+
uv run metta pytest --changed
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Tests cover auth/login, the Python API, player identity switching, and CLI plugin wiring; a `BUILD.bazel` exists under
|
|
35
|
+
`tests/`.
|
|
36
|
+
|
|
37
|
+
## Lint
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv run metta lint --fix # ruff (also runs via the Edit/Write hook)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Source layout (`src/softmax/`)
|
|
44
|
+
|
|
45
|
+
- `cli.py` — the Typer app; mounts the `player` subapp via `add_typer`.
|
|
46
|
+
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
47
|
+
- `players.py` — player API calls (`/observatory/players*`) and the `player list/use/unset` subapp; `coworld`
|
|
48
|
+
mounts this same subapp.
|
|
49
|
+
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
50
|
+
- `_console.py` — shared rich console helpers.
|
|
51
|
+
|
|
52
|
+
## Gotchas
|
|
53
|
+
|
|
54
|
+
- Versioned via `setuptools_scm` off `softmax-v*` git tags (`fallback_version = 0.0.0`).
|
|
55
|
+
- Downstream packages pin an exact `softmax-cli==X.Y.Z`; bumping the public auth API can break them — coordinate version
|
|
56
|
+
bumps with consumers like `coworld`.
|
|
57
|
+
- `player_sessions[server]` in `credentials.yaml` is a structured object (`active` pointer + per-player `cache` of
|
|
58
|
+
`{token, expires_at}`), not a flat token string. Use the typed helpers in `auth.py`
|
|
59
|
+
(`set_active_player_session`, `clear_active_player_session`, `get_active_player_id`, `get_cached_player_session`,
|
|
60
|
+
`load_player_session`); `load_current_token` returns the active player token when one is selected, else the user
|
|
61
|
+
token. `softmax player use/unset` drives this.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softmax-cli
|
|
3
|
-
Version: 0.26.
|
|
3
|
+
Version: 0.26.20
|
|
4
4
|
Summary: Softmax CLI — authentication and account tools
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -16,19 +16,18 @@ Requires-Dist: typer>=0.19.2
|
|
|
16
16
|
Requires-Dist: uvicorn>=0.34.0
|
|
17
17
|
Provides-Extra: test
|
|
18
18
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
19
|
+
Requires-Dist: pytest-httpserver; extra == "test"
|
|
19
20
|
|
|
20
21
|
# softmax-cli
|
|
21
22
|
|
|
22
23
|
The `softmax` command-line tool: authentication and account management for Softmax / Observatory. It provides
|
|
23
|
-
browser-based login (with a local callback server), token storage,
|
|
24
|
-
`coworld` — depend on it for auth.
|
|
25
|
-
subcommand.
|
|
24
|
+
browser-based login (with a local callback server), token storage, account status, and player identity switching.
|
|
25
|
+
Other packages — notably `coworld` — depend on it for auth.
|
|
26
26
|
|
|
27
27
|
## Install
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
uv tool install softmax-cli
|
|
31
|
-
uv tool install "softmax-cli[cogames]" # with the cogames subcommand
|
|
30
|
+
uv tool install softmax-cli
|
|
32
31
|
```
|
|
33
32
|
|
|
34
33
|
Within the metta workspace it is available via `uv sync`.
|
|
@@ -41,7 +40,9 @@ uv run softmax status # show current auth status
|
|
|
41
40
|
uv run softmax logout
|
|
42
41
|
uv run softmax get-token # print the stored token
|
|
43
42
|
uv run softmax set-token # store a token manually
|
|
44
|
-
uv run softmax
|
|
43
|
+
uv run softmax player list # list your players (active one highlighted)
|
|
44
|
+
uv run softmax player use ply_... # act as a player in all auth-backed commands
|
|
45
|
+
uv run softmax player unset # revert to your main user credential
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
## Development
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
# softmax-cli
|
|
2
2
|
|
|
3
3
|
The `softmax` command-line tool: authentication and account management for Softmax / Observatory. It provides
|
|
4
|
-
browser-based login (with a local callback server), token storage,
|
|
5
|
-
`coworld` — depend on it for auth.
|
|
6
|
-
subcommand.
|
|
4
|
+
browser-based login (with a local callback server), token storage, account status, and player identity switching.
|
|
5
|
+
Other packages — notably `coworld` — depend on it for auth.
|
|
7
6
|
|
|
8
7
|
## Install
|
|
9
8
|
|
|
10
9
|
```bash
|
|
11
|
-
uv tool install softmax-cli
|
|
12
|
-
uv tool install "softmax-cli[cogames]" # with the cogames subcommand
|
|
10
|
+
uv tool install softmax-cli
|
|
13
11
|
```
|
|
14
12
|
|
|
15
13
|
Within the metta workspace it is available via `uv sync`.
|
|
@@ -22,7 +20,9 @@ uv run softmax status # show current auth status
|
|
|
22
20
|
uv run softmax logout
|
|
23
21
|
uv run softmax get-token # print the stored token
|
|
24
22
|
uv run softmax set-token # store a token manually
|
|
25
|
-
uv run softmax
|
|
23
|
+
uv run softmax player list # list your players (active one highlighted)
|
|
24
|
+
uv run softmax player use ply_... # act as a player in all auth-backed commands
|
|
25
|
+
uv run softmax player unset # revert to your main user credential
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
## Development
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import importlib
|
|
4
3
|
import sys
|
|
5
4
|
from pkgutil import extend_path
|
|
6
5
|
|
|
@@ -43,10 +42,4 @@ def login(
|
|
|
43
42
|
return token
|
|
44
43
|
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
if name == "cogames":
|
|
48
|
-
return importlib.import_module("softmax.cogames")
|
|
49
|
-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
__all__ = ["login", "cogames"]
|
|
45
|
+
__all__ = ["login"]
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
from datetime import UTC, datetime
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
8
9
|
|
|
@@ -82,7 +83,14 @@ def load_user_token(*, server: str) -> str | None:
|
|
|
82
83
|
def save_user_token(*, server: str, token: str) -> None:
|
|
83
84
|
server = server.rstrip("/")
|
|
84
85
|
data = _load_data()
|
|
85
|
-
data.setdefault("tokens", {})
|
|
86
|
+
tokens = data.setdefault("tokens", {})
|
|
87
|
+
# Player sessions are owned by whoever held the user token. When the user
|
|
88
|
+
# credential changes (account switch, `login --force`, CI token rotation),
|
|
89
|
+
# the old player sessions belong to the previous user, so drop them rather
|
|
90
|
+
# than letting load_current_token keep acting as that stale player.
|
|
91
|
+
if tokens.get(server) != token:
|
|
92
|
+
data.get("player_sessions", {}).pop(server, None)
|
|
93
|
+
tokens[server] = token
|
|
86
94
|
_save_data(data)
|
|
87
95
|
|
|
88
96
|
|
|
@@ -97,33 +105,91 @@ def _delete_user_token(*, server: str) -> bool:
|
|
|
97
105
|
return True
|
|
98
106
|
|
|
99
107
|
|
|
108
|
+
# --- player sessions ---
|
|
109
|
+
#
|
|
110
|
+
# `player_sessions[server]` holds the active player pointer plus a per-player
|
|
111
|
+
# token cache so `coworld player use <id>` can re-activate a player without
|
|
112
|
+
# re-minting a token until it expires. `load_current_token` returns the active
|
|
113
|
+
# player's token when one is selected, so every auth-backed command transparently
|
|
114
|
+
# acts as that player. Shape:
|
|
115
|
+
# player_sessions:
|
|
116
|
+
# <server>:
|
|
117
|
+
# active: ply_aaa # currently selected player id, or absent
|
|
118
|
+
# cache:
|
|
119
|
+
# ply_aaa: {token: ply_..., expires_at: "2026-..."}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CachedPlayerSession(BaseModel):
|
|
123
|
+
token: str
|
|
124
|
+
expires_at: datetime
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ServerPlayerSessions(BaseModel):
|
|
128
|
+
active: str | None = None
|
|
129
|
+
cache: dict[str, CachedPlayerSession] = Field(default_factory=dict)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _server_sessions(data: dict, server: str) -> ServerPlayerSessions:
|
|
133
|
+
raw = (data.get("player_sessions") or {}).get(server.rstrip("/")) or {}
|
|
134
|
+
return ServerPlayerSessions.model_validate(raw)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _store_server_sessions(data: dict, server: str, sessions: ServerPlayerSessions) -> None:
|
|
138
|
+
data.setdefault("player_sessions", {})[server.rstrip("/")] = sessions.model_dump(mode="json")
|
|
139
|
+
|
|
140
|
+
|
|
100
141
|
def load_player_session(*, server: str) -> str | None:
|
|
101
|
-
|
|
102
|
-
sessions = _load_data().get("player_sessions", {})
|
|
103
|
-
token = sessions.get(server)
|
|
104
|
-
return token if isinstance(token, str) else None
|
|
142
|
+
"""The active player's token, or None when no player is selected or expired.
|
|
105
143
|
|
|
144
|
+
Returns None for expired sessions so `load_current_token` falls through to
|
|
145
|
+
the user token rather than sending a dead player token that produces 401s.
|
|
146
|
+
"""
|
|
147
|
+
sessions = _server_sessions(_load_data(), server)
|
|
148
|
+
if sessions.active is None:
|
|
149
|
+
return None
|
|
150
|
+
cached = sessions.cache.get(sessions.active)
|
|
151
|
+
if cached is None:
|
|
152
|
+
return None
|
|
153
|
+
if cached.expires_at <= datetime.now(UTC):
|
|
154
|
+
return None
|
|
155
|
+
return cached.token
|
|
106
156
|
|
|
107
|
-
|
|
108
|
-
|
|
157
|
+
|
|
158
|
+
def get_active_player_id(*, server: str) -> str | None:
|
|
159
|
+
return _server_sessions(_load_data(), server).active
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_cached_player_session(*, server: str, player_id: str) -> CachedPlayerSession | None:
|
|
163
|
+
return _server_sessions(_load_data(), server).cache.get(player_id)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def set_active_player_session(*, server: str, player_id: str, token: str, expires_at: datetime) -> None:
|
|
109
167
|
data = _load_data()
|
|
110
|
-
data
|
|
168
|
+
sessions = _server_sessions(data, server)
|
|
169
|
+
sessions.cache[player_id] = CachedPlayerSession(token=token, expires_at=expires_at)
|
|
170
|
+
sessions.active = player_id
|
|
171
|
+
_store_server_sessions(data, server, sessions)
|
|
111
172
|
_save_data(data)
|
|
112
173
|
|
|
113
174
|
|
|
114
|
-
def
|
|
115
|
-
|
|
175
|
+
def clear_active_player_session(*, server: str) -> bool:
|
|
176
|
+
"""Clear the active player pointer, reverting to the user token.
|
|
177
|
+
|
|
178
|
+
Keeps the cached tokens so a later `use` of the same player can reuse them.
|
|
179
|
+
Leaves the user token untouched.
|
|
180
|
+
"""
|
|
116
181
|
data = _load_data()
|
|
117
|
-
sessions = data
|
|
118
|
-
if
|
|
182
|
+
sessions = _server_sessions(data, server)
|
|
183
|
+
if sessions.active is None:
|
|
119
184
|
return False
|
|
120
|
-
|
|
185
|
+
sessions.active = None
|
|
186
|
+
_store_server_sessions(data, server, sessions)
|
|
121
187
|
_save_data(data)
|
|
122
188
|
return True
|
|
123
189
|
|
|
124
190
|
|
|
125
191
|
def load_current_token(*, server: str) -> str | None:
|
|
126
|
-
"""Returns
|
|
192
|
+
"""Returns the active player session token if present, else the user token."""
|
|
127
193
|
return load_player_session(server=server) or load_user_token(server=server)
|
|
128
194
|
|
|
129
195
|
|
|
@@ -132,8 +198,13 @@ def has_saved_token(*, server: str) -> bool:
|
|
|
132
198
|
|
|
133
199
|
|
|
134
200
|
def delete_all_tokens(*, server: str) -> bool:
|
|
201
|
+
server = server.rstrip("/")
|
|
135
202
|
deleted_token = _delete_user_token(server=server)
|
|
136
|
-
|
|
203
|
+
data = _load_data()
|
|
204
|
+
sessions = data.get("player_sessions", {})
|
|
205
|
+
deleted_session = sessions.pop(server, None) is not None
|
|
206
|
+
if deleted_session:
|
|
207
|
+
_save_data(data)
|
|
137
208
|
return deleted_token or deleted_session
|
|
138
209
|
|
|
139
210
|
|
|
@@ -20,6 +20,7 @@ from softmax.auth import (
|
|
|
20
20
|
try_exchange_auth_code,
|
|
21
21
|
)
|
|
22
22
|
from softmax.perform_login import do_interactive_login_for_token
|
|
23
|
+
from softmax.players import player_app
|
|
23
24
|
|
|
24
25
|
app = typer.Typer(
|
|
25
26
|
help="Softmax CLI — authentication and account tools",
|
|
@@ -27,6 +28,7 @@ app = typer.Typer(
|
|
|
27
28
|
no_args_is_help=True,
|
|
28
29
|
rich_markup_mode="rich",
|
|
29
30
|
)
|
|
31
|
+
app.add_typer(player_app, name="player")
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
def _build_manual_exchange_command(server: str | None = None) -> str:
|
|
@@ -167,6 +169,8 @@ def status_cmd(
|
|
|
167
169
|
),
|
|
168
170
|
) -> None:
|
|
169
171
|
"""Check authentication status via /whoami."""
|
|
172
|
+
from softmax.auth import get_active_player_id, load_player_session # noqa: PLC0415
|
|
173
|
+
|
|
170
174
|
api_server = server or get_api_server()
|
|
171
175
|
token = load_current_token(server=api_server)
|
|
172
176
|
if not token:
|
|
@@ -175,6 +179,15 @@ def status_cmd(
|
|
|
175
179
|
|
|
176
180
|
session = fetch_cogames_whoami(api_server=api_server, token=token)
|
|
177
181
|
if session.subject_type == "anonymous":
|
|
182
|
+
player_token = load_player_session(server=api_server)
|
|
183
|
+
if player_token and player_token == token:
|
|
184
|
+
player_id = get_active_player_id(server=api_server)
|
|
185
|
+
console.print(
|
|
186
|
+
f"[red]Active player session expired[/red] ({player_id})."
|
|
187
|
+
" Run [cyan]softmax player use[/cyan] to refresh"
|
|
188
|
+
" or [cyan]softmax player unset[/cyan] to revert to your user credential."
|
|
189
|
+
)
|
|
190
|
+
raise typer.Exit(1)
|
|
178
191
|
console.print("[red]Token is invalid or expired.[/red] Run [cyan]softmax login[/cyan] to re-authenticate.")
|
|
179
192
|
raise typer.Exit(1)
|
|
180
193
|
console.print("[green]Authenticated[/green]")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Player identity management: list players and switch the active player session.
|
|
2
|
+
|
|
3
|
+
A player is an identity owned by your Softmax user. Activating one stores a 24h
|
|
4
|
+
player session in ~/.softmax/credentials.yaml; `load_current_token` then returns
|
|
5
|
+
the player token, so every auth-backed command in any CLI built on softmax-cli
|
|
6
|
+
(`softmax`, `coworld`, ...) transparently acts as that player.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import typer
|
|
17
|
+
from pydantic import BaseModel, ConfigDict
|
|
18
|
+
from rich import box
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from softmax import auth
|
|
22
|
+
from softmax._console import console
|
|
23
|
+
from softmax.auth import DEFAULT_API_SERVER
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PlayerResponse(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="allow")
|
|
28
|
+
|
|
29
|
+
id: str # ply_<uuid>
|
|
30
|
+
name: str
|
|
31
|
+
is_default: bool = False
|
|
32
|
+
avatar_url: str | None = None
|
|
33
|
+
created_at: datetime
|
|
34
|
+
disabled_at: datetime | None = None
|
|
35
|
+
user_id: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PlayerLoginResponse(BaseModel):
|
|
39
|
+
model_config = ConfigDict(extra="allow")
|
|
40
|
+
|
|
41
|
+
player_id: str
|
|
42
|
+
token: str
|
|
43
|
+
expires_at: datetime
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_players(*, server: str, token: str) -> list[PlayerResponse]:
|
|
47
|
+
response = httpx.get(
|
|
48
|
+
f"{server.rstrip('/')}/observatory/players",
|
|
49
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
50
|
+
timeout=10.0,
|
|
51
|
+
)
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
return [PlayerResponse.model_validate(entry) for entry in response.json()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def login_player(*, server: str, token: str, player_id: str) -> PlayerLoginResponse:
|
|
57
|
+
response = httpx.post(
|
|
58
|
+
f"{server.rstrip('/')}/observatory/players/{player_id}/login",
|
|
59
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
60
|
+
timeout=10.0,
|
|
61
|
+
)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
return PlayerLoginResponse.model_validate(response.json())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _user_token(server: str) -> str:
|
|
67
|
+
# Player routes require a real user identity (USER_AUTH) and reject
|
|
68
|
+
# player-scoped tokens, so resolve the user token directly rather than
|
|
69
|
+
# load_current_token, which would pick up the active player session.
|
|
70
|
+
token = auth.load_user_token(server=server)
|
|
71
|
+
if token is None:
|
|
72
|
+
raise RuntimeError(f"Not authenticated. Run: softmax login --server {server}")
|
|
73
|
+
return token
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
player_app = typer.Typer(no_args_is_help=True, help="Manage the active player identity.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@player_app.command("list")
|
|
80
|
+
def player_list(
|
|
81
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
82
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False,
|
|
83
|
+
) -> None:
|
|
84
|
+
active_id = auth.get_active_player_id(server=server)
|
|
85
|
+
players = list_players(server=server, token=_user_token(server))
|
|
86
|
+
if json_output:
|
|
87
|
+
payload = [{**p.model_dump(mode="json"), "active": p.id == active_id} for p in players]
|
|
88
|
+
print(json.dumps(payload, indent=2))
|
|
89
|
+
return
|
|
90
|
+
table = Table(box=box.SIMPLE_HEAVY, show_lines=False, pad_edge=False)
|
|
91
|
+
table.add_column("")
|
|
92
|
+
table.add_column("ID")
|
|
93
|
+
table.add_column("Name")
|
|
94
|
+
table.add_column("Default")
|
|
95
|
+
for p in players:
|
|
96
|
+
is_active = p.id == active_id
|
|
97
|
+
marker = "[green]●[/green]" if is_active else ""
|
|
98
|
+
name = f"[bold]{p.name}[/bold]" if is_active else p.name
|
|
99
|
+
table.add_row(marker, p.id, name, "yes" if p.is_default else "no")
|
|
100
|
+
console.print(table)
|
|
101
|
+
if active_id is None:
|
|
102
|
+
console.print("[dim]No active player; commands act as your main user.[/dim]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@player_app.command("use")
|
|
106
|
+
def player_use(
|
|
107
|
+
player_id: Annotated[str, typer.Argument(help="Player ID (ply_...) to act as.")],
|
|
108
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
109
|
+
) -> None:
|
|
110
|
+
# Reuse a cached, non-expired session rather than minting a fresh token.
|
|
111
|
+
cached = auth.get_cached_player_session(server=server, player_id=player_id)
|
|
112
|
+
if cached is not None and cached.expires_at - timedelta(minutes=5) > datetime.now(UTC):
|
|
113
|
+
auth.set_active_player_session(
|
|
114
|
+
server=server, player_id=player_id, token=cached.token, expires_at=cached.expires_at
|
|
115
|
+
)
|
|
116
|
+
console.print(f"[green]Active player set to[/green] [bold]{player_id}[/bold] [dim](cached session)[/dim]")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
token = _user_token(server)
|
|
120
|
+
players = list_players(server=server, token=token)
|
|
121
|
+
match = next((p for p in players if p.id == player_id), None)
|
|
122
|
+
if match is None:
|
|
123
|
+
console.print(f"[red]Player '{player_id}' not found in your account.[/red]")
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
login = login_player(server=server, token=token, player_id=player_id)
|
|
126
|
+
auth.set_active_player_session(server=server, player_id=player_id, token=login.token, expires_at=login.expires_at)
|
|
127
|
+
console.print(f"[green]Active player set to[/green] [bold]{match.name}[/bold] ({player_id})")
|
|
128
|
+
console.print(f"[dim]Session expires:[/dim] {login.expires_at.isoformat()}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@player_app.command("unset")
|
|
132
|
+
def player_unset(
|
|
133
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
134
|
+
) -> None:
|
|
135
|
+
if auth.clear_active_player_session(server=server):
|
|
136
|
+
console.print("[green]Reverted to your main user credential.[/green]")
|
|
137
|
+
else:
|
|
138
|
+
console.print("[yellow]No active player was set.[/yellow]")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softmax-cli
|
|
3
|
-
Version: 0.26.
|
|
3
|
+
Version: 0.26.20
|
|
4
4
|
Summary: Softmax CLI — authentication and account tools
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -16,19 +16,18 @@ Requires-Dist: typer>=0.19.2
|
|
|
16
16
|
Requires-Dist: uvicorn>=0.34.0
|
|
17
17
|
Provides-Extra: test
|
|
18
18
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
19
|
+
Requires-Dist: pytest-httpserver; extra == "test"
|
|
19
20
|
|
|
20
21
|
# softmax-cli
|
|
21
22
|
|
|
22
23
|
The `softmax` command-line tool: authentication and account management for Softmax / Observatory. It provides
|
|
23
|
-
browser-based login (with a local callback server), token storage,
|
|
24
|
-
`coworld` — depend on it for auth.
|
|
25
|
-
subcommand.
|
|
24
|
+
browser-based login (with a local callback server), token storage, account status, and player identity switching.
|
|
25
|
+
Other packages — notably `coworld` — depend on it for auth.
|
|
26
26
|
|
|
27
27
|
## Install
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
uv tool install softmax-cli
|
|
31
|
-
uv tool install "softmax-cli[cogames]" # with the cogames subcommand
|
|
30
|
+
uv tool install softmax-cli
|
|
32
31
|
```
|
|
33
32
|
|
|
34
33
|
Within the metta workspace it is available via `uv sync`.
|
|
@@ -41,7 +40,9 @@ uv run softmax status # show current auth status
|
|
|
41
40
|
uv run softmax logout
|
|
42
41
|
uv run softmax get-token # print the stored token
|
|
43
42
|
uv run softmax set-token # store a token manually
|
|
44
|
-
uv run softmax
|
|
43
|
+
uv run softmax player list # list your players (active one highlighted)
|
|
44
|
+
uv run softmax player use ply_... # act as a player in all auth-backed commands
|
|
45
|
+
uv run softmax player unset # revert to your main user credential
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
## Development
|
|
@@ -7,8 +7,8 @@ src/softmax/__init__.py
|
|
|
7
7
|
src/softmax/_console.py
|
|
8
8
|
src/softmax/auth.py
|
|
9
9
|
src/softmax/cli.py
|
|
10
|
-
src/softmax/cogames.py
|
|
11
10
|
src/softmax/perform_login.py
|
|
11
|
+
src/softmax/players.py
|
|
12
12
|
src/softmax_cli.egg-info/PKG-INFO
|
|
13
13
|
src/softmax_cli.egg-info/SOURCES.txt
|
|
14
14
|
src/softmax_cli.egg-info/dependency_links.txt
|
|
@@ -17,4 +17,5 @@ src/softmax_cli.egg-info/requires.txt
|
|
|
17
17
|
src/softmax_cli.egg-info/top_level.txt
|
|
18
18
|
tests/BUILD.bazel
|
|
19
19
|
tests/test_auth_login.py
|
|
20
|
+
tests/test_player_cli.py
|
|
20
21
|
tests/test_python_api.py
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
from typer.testing import CliRunner
|
|
7
8
|
|
|
8
9
|
import softmax.perform_login as auth_module
|
|
9
|
-
from softmax.auth import build_browser_login_url, load_user_token,
|
|
10
|
+
from softmax.auth import build_browser_login_url, load_user_token, save_user_token, set_active_player_session
|
|
10
11
|
from softmax.cli import _build_manual_exchange_command, app
|
|
11
12
|
from softmax.perform_login import do_interactive_login_for_token
|
|
12
13
|
|
|
13
14
|
runner = CliRunner()
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def _activate_player(server: str, token: str, player_id: str = "ply_alpha") -> None:
|
|
18
|
+
set_active_player_session(
|
|
19
|
+
server=server,
|
|
20
|
+
player_id=player_id,
|
|
21
|
+
token=token,
|
|
22
|
+
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
16
26
|
def test_authenticate_exchanges_code_via_callback(
|
|
17
27
|
monkeypatch: pytest.MonkeyPatch,
|
|
18
28
|
tmp_path,
|
|
@@ -124,7 +134,7 @@ def test_status_prints_active_subject_details(
|
|
|
124
134
|
tmp_path,
|
|
125
135
|
) -> None:
|
|
126
136
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
127
|
-
|
|
137
|
+
_activate_player("https://softmax.com/api", "player-session-token")
|
|
128
138
|
|
|
129
139
|
class FakeResponse:
|
|
130
140
|
def raise_for_status(self) -> None:
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pytest_httpserver import HTTPServer
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from softmax.auth import (
|
|
11
|
+
clear_active_player_session,
|
|
12
|
+
get_active_player_id,
|
|
13
|
+
load_current_token,
|
|
14
|
+
load_player_session,
|
|
15
|
+
load_user_token,
|
|
16
|
+
save_user_token,
|
|
17
|
+
set_active_player_session,
|
|
18
|
+
)
|
|
19
|
+
from softmax.cli import app
|
|
20
|
+
|
|
21
|
+
PLAYER_ALPHA = {
|
|
22
|
+
"id": "ply_00000000-0000-0000-0000-0000000000a1",
|
|
23
|
+
"name": "Alpha",
|
|
24
|
+
"is_default": True,
|
|
25
|
+
"avatar_url": None,
|
|
26
|
+
"created_at": "2026-06-05T12:00:00Z",
|
|
27
|
+
"disabled_at": None,
|
|
28
|
+
"user_id": "usr_owner",
|
|
29
|
+
}
|
|
30
|
+
PLAYER_BETA = {
|
|
31
|
+
"id": "ply_00000000-0000-0000-0000-0000000000b2",
|
|
32
|
+
"name": "Beta",
|
|
33
|
+
"is_default": False,
|
|
34
|
+
"avatar_url": None,
|
|
35
|
+
"created_at": "2026-06-06T12:00:00Z",
|
|
36
|
+
"disabled_at": None,
|
|
37
|
+
"user_id": "usr_owner",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture(autouse=True)
|
|
42
|
+
def _sandbox_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
43
|
+
# Isolate ~/.softmax/credentials.yaml under tmp.
|
|
44
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def _mock_user_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
49
|
+
# Stub the user token for commands that authenticate against the API. Tests
|
|
50
|
+
# that assert on real token storage seed it via save_user_token instead.
|
|
51
|
+
monkeypatch.setattr("softmax.auth.load_user_token", lambda *, server: "user-token")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _activate(server: str, player_id: str, token: str = "ply_session", hours: int = 24) -> None:
|
|
55
|
+
set_active_player_session(
|
|
56
|
+
server=server,
|
|
57
|
+
player_id=player_id,
|
|
58
|
+
token=token,
|
|
59
|
+
expires_at=datetime.now(UTC) + timedelta(hours=hours),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_list_marks_active(httpserver: HTTPServer, _mock_user_auth: None) -> None:
|
|
64
|
+
server = httpserver.url_for("")
|
|
65
|
+
_activate(server, PLAYER_BETA["id"])
|
|
66
|
+
httpserver.expect_request(
|
|
67
|
+
"/observatory/players",
|
|
68
|
+
method="GET",
|
|
69
|
+
headers={"Authorization": "Bearer user-token"},
|
|
70
|
+
).respond_with_json([PLAYER_ALPHA, PLAYER_BETA])
|
|
71
|
+
|
|
72
|
+
result = CliRunner().invoke(app, ["player", "list", "--server", server])
|
|
73
|
+
|
|
74
|
+
assert result.exit_code == 0, result.output
|
|
75
|
+
assert "Alpha" in result.output
|
|
76
|
+
assert "Beta" in result.output
|
|
77
|
+
# The active marker renders on the Beta row.
|
|
78
|
+
assert "●" in result.output
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_list_json_includes_active_flag(httpserver: HTTPServer, _mock_user_auth: None) -> None:
|
|
82
|
+
server = httpserver.url_for("")
|
|
83
|
+
_activate(server, PLAYER_ALPHA["id"])
|
|
84
|
+
httpserver.expect_request("/observatory/players", method="GET").respond_with_json([PLAYER_ALPHA, PLAYER_BETA])
|
|
85
|
+
|
|
86
|
+
result = CliRunner().invoke(app, ["player", "list", "--server", server, "--json"])
|
|
87
|
+
|
|
88
|
+
assert result.exit_code == 0, result.output
|
|
89
|
+
entries = json.loads(result.output)
|
|
90
|
+
by_id = {e["id"]: e["active"] for e in entries}
|
|
91
|
+
assert by_id[PLAYER_ALPHA["id"]] is True
|
|
92
|
+
assert by_id[PLAYER_BETA["id"]] is False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_use_mints_and_activates(httpserver: HTTPServer, _mock_user_auth: None) -> None:
|
|
96
|
+
server = httpserver.url_for("")
|
|
97
|
+
expires_at = (datetime.now(UTC) + timedelta(hours=24)).isoformat()
|
|
98
|
+
httpserver.expect_request(
|
|
99
|
+
"/observatory/players",
|
|
100
|
+
method="GET",
|
|
101
|
+
headers={"Authorization": "Bearer user-token"},
|
|
102
|
+
).respond_with_json([PLAYER_ALPHA, PLAYER_BETA])
|
|
103
|
+
httpserver.expect_request(
|
|
104
|
+
f"/observatory/players/{PLAYER_BETA['id']}/login",
|
|
105
|
+
method="POST",
|
|
106
|
+
headers={"Authorization": "Bearer user-token"},
|
|
107
|
+
).respond_with_json({"player_id": PLAYER_BETA["id"], "token": "ply_minted", "expires_at": expires_at})
|
|
108
|
+
|
|
109
|
+
result = CliRunner().invoke(app, ["player", "use", PLAYER_BETA["id"], "--server", server])
|
|
110
|
+
|
|
111
|
+
assert result.exit_code == 0, result.output
|
|
112
|
+
assert get_active_player_id(server=server) == PLAYER_BETA["id"]
|
|
113
|
+
assert load_player_session(server=server) == "ply_minted"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_use_reuses_cached_token_without_network(httpserver: HTTPServer) -> None:
|
|
117
|
+
server = httpserver.url_for("")
|
|
118
|
+
# Pre-seed a fresh cached session, then unset so it stays cached but inactive.
|
|
119
|
+
_activate(server, PLAYER_BETA["id"], token="ply_cached")
|
|
120
|
+
clear_active_player_session(server=server)
|
|
121
|
+
|
|
122
|
+
result = CliRunner().invoke(app, ["player", "use", PLAYER_BETA["id"], "--server", server])
|
|
123
|
+
|
|
124
|
+
assert result.exit_code == 0, result.output
|
|
125
|
+
assert "cached session" in result.output
|
|
126
|
+
assert get_active_player_id(server=server) == PLAYER_BETA["id"]
|
|
127
|
+
assert load_player_session(server=server) == "ply_cached"
|
|
128
|
+
# No HTTP traffic should have hit the server.
|
|
129
|
+
assert httpserver.log == []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_use_unknown_id_errors_without_login(httpserver: HTTPServer, _mock_user_auth: None) -> None:
|
|
133
|
+
server = httpserver.url_for("")
|
|
134
|
+
httpserver.expect_request("/observatory/players", method="GET").respond_with_json([PLAYER_ALPHA])
|
|
135
|
+
|
|
136
|
+
result = CliRunner().invoke(app, ["player", "use", "ply_does_not_exist", "--server", server])
|
|
137
|
+
|
|
138
|
+
assert result.exit_code == 1, result.output
|
|
139
|
+
assert "not found" in result.output
|
|
140
|
+
# Only the list request was made; no login was attempted.
|
|
141
|
+
assert all("/login" not in request.path for request, _ in httpserver.log)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_unset_clears_active_keeps_user_token(httpserver: HTTPServer) -> None:
|
|
145
|
+
server = httpserver.url_for("")
|
|
146
|
+
save_user_token(server=server, token="user-token")
|
|
147
|
+
_activate(server, PLAYER_ALPHA["id"])
|
|
148
|
+
|
|
149
|
+
result = CliRunner().invoke(app, ["player", "unset", "--server", server])
|
|
150
|
+
|
|
151
|
+
assert result.exit_code == 0, result.output
|
|
152
|
+
assert "Reverted" in result.output
|
|
153
|
+
assert load_player_session(server=server) is None
|
|
154
|
+
assert load_user_token(server=server) == "user-token"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_unset_when_no_active_player(httpserver: HTTPServer) -> None:
|
|
158
|
+
server = httpserver.url_for("")
|
|
159
|
+
|
|
160
|
+
result = CliRunner().invoke(app, ["player", "unset", "--server", server])
|
|
161
|
+
|
|
162
|
+
assert result.exit_code == 0, result.output
|
|
163
|
+
assert "No active player" in result.output
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_active_player_inherited_by_load_current_token(httpserver: HTTPServer, _mock_user_auth: None) -> None:
|
|
167
|
+
server = httpserver.url_for("")
|
|
168
|
+
expires_at = (datetime.now(UTC) + timedelta(hours=24)).isoformat()
|
|
169
|
+
httpserver.expect_request("/observatory/players", method="GET").respond_with_json([PLAYER_ALPHA])
|
|
170
|
+
httpserver.expect_request(f"/observatory/players/{PLAYER_ALPHA['id']}/login", method="POST").respond_with_json(
|
|
171
|
+
{"player_id": PLAYER_ALPHA["id"], "token": "ply_minted", "expires_at": expires_at}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
result = CliRunner().invoke(app, ["player", "use", PLAYER_ALPHA["id"], "--server", server])
|
|
175
|
+
assert result.exit_code == 0, result.output
|
|
176
|
+
|
|
177
|
+
# Other commands resolve their token via load_current_token, which must now
|
|
178
|
+
# return the active player token.
|
|
179
|
+
assert load_current_token(server=server) == "ply_minted"
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import importlib
|
|
4
3
|
import sys
|
|
5
|
-
from
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
6
5
|
|
|
7
6
|
import pytest
|
|
8
7
|
|
|
9
8
|
import softmax
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
from softmax.auth import (
|
|
10
|
+
clear_active_player_session,
|
|
11
|
+
get_cached_player_session,
|
|
12
|
+
load_current_token,
|
|
13
|
+
load_player_session,
|
|
14
|
+
load_user_token,
|
|
15
|
+
save_user_token,
|
|
16
|
+
set_active_player_session,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _activate_player(server: str, token: str, player_id: str = "ply_alpha") -> None:
|
|
21
|
+
set_active_player_session(
|
|
22
|
+
server=server,
|
|
23
|
+
player_id=player_id,
|
|
24
|
+
token=token,
|
|
25
|
+
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
|
26
|
+
)
|
|
12
27
|
|
|
13
28
|
|
|
14
29
|
def test_login_returns_saved_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
@@ -42,7 +57,7 @@ def test_login_runs_interactive_flow_when_missing_token(monkeypatch: pytest.Monk
|
|
|
42
57
|
def test_login_ignores_player_session_without_saved_user_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
43
58
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
44
59
|
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
|
45
|
-
|
|
60
|
+
_activate_player("https://softmax.com/api", "active-only-token")
|
|
46
61
|
|
|
47
62
|
called = {"interactive": False}
|
|
48
63
|
|
|
@@ -65,88 +80,6 @@ def test_login_requires_tty_when_missing_token(monkeypatch: pytest.MonkeyPatch,
|
|
|
65
80
|
softmax.login()
|
|
66
81
|
|
|
67
82
|
|
|
68
|
-
def test_softmax_module_exposes_cogames_submodule() -> None:
|
|
69
|
-
assert cast(Any, softmax).cogames is importlib.import_module("softmax.cogames")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_softmax_cogames_player_list_uses_expected_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
73
|
-
captured: dict[str, object] = {}
|
|
74
|
-
|
|
75
|
-
class FakeClient:
|
|
76
|
-
def __init__(self, *, server_url: str, token: str) -> None:
|
|
77
|
-
captured["server_url"] = server_url
|
|
78
|
-
captured["token"] = token
|
|
79
|
-
|
|
80
|
-
def __enter__(self) -> "FakeClient":
|
|
81
|
-
return self
|
|
82
|
-
|
|
83
|
-
def __exit__(self, exc_type, exc, tb) -> None:
|
|
84
|
-
return None
|
|
85
|
-
|
|
86
|
-
def list_players(self) -> list[str]:
|
|
87
|
-
return ["alpha", "beta"]
|
|
88
|
-
|
|
89
|
-
monkeypatch.setattr(softmax_cogames, "_get_tournament_client_class", lambda: FakeClient)
|
|
90
|
-
|
|
91
|
-
assert cast(Any, softmax).cogames.player.list("softmax-token") == ["alpha", "beta"]
|
|
92
|
-
assert captured == {
|
|
93
|
-
"server_url": "https://softmax.com/api",
|
|
94
|
-
"token": "softmax-token",
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def test_softmax_cogames_login_returns_player_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
99
|
-
class FakeLoginResponse:
|
|
100
|
-
token = "player-token"
|
|
101
|
-
|
|
102
|
-
class FakeClient:
|
|
103
|
-
def __init__(self, *, server_url: str, token: str) -> None:
|
|
104
|
-
self.server_url = server_url
|
|
105
|
-
self.token = token
|
|
106
|
-
|
|
107
|
-
def __enter__(self) -> "FakeClient":
|
|
108
|
-
return self
|
|
109
|
-
|
|
110
|
-
def __exit__(self, exc_type, exc, tb) -> None:
|
|
111
|
-
return None
|
|
112
|
-
|
|
113
|
-
def login_player(self, player_id: str) -> FakeLoginResponse:
|
|
114
|
-
assert self.server_url == "https://softmax.com/api"
|
|
115
|
-
assert self.token == "softmax-token"
|
|
116
|
-
assert player_id == "ply_alpha"
|
|
117
|
-
return FakeLoginResponse()
|
|
118
|
-
|
|
119
|
-
monkeypatch.setattr(softmax_cogames, "_get_tournament_client_class", lambda: FakeClient)
|
|
120
|
-
|
|
121
|
-
assert cast(Any, softmax).cogames.login("softmax-token", "ply_alpha") == "player-token"
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_softmax_cogames_login_response_returns_full_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
125
|
-
class FakeLoginResponse:
|
|
126
|
-
token = "player-token"
|
|
127
|
-
expires_at = "2026-02-21T12:00:00Z"
|
|
128
|
-
|
|
129
|
-
class FakeClient:
|
|
130
|
-
def __init__(self, *, server_url: str, token: str) -> None:
|
|
131
|
-
pass
|
|
132
|
-
|
|
133
|
-
def __enter__(self) -> "FakeClient":
|
|
134
|
-
return self
|
|
135
|
-
|
|
136
|
-
def __exit__(self, exc_type, exc, tb) -> None:
|
|
137
|
-
return None
|
|
138
|
-
|
|
139
|
-
def login_player(self, player_id: str) -> FakeLoginResponse:
|
|
140
|
-
assert player_id == "ply_alpha"
|
|
141
|
-
return FakeLoginResponse()
|
|
142
|
-
|
|
143
|
-
monkeypatch.setattr(softmax_cogames, "_get_tournament_client_class", lambda: FakeClient)
|
|
144
|
-
|
|
145
|
-
response = cast(Any, softmax).cogames.login_response("softmax-token", "ply_alpha")
|
|
146
|
-
assert response.token == "player-token"
|
|
147
|
-
assert response.expires_at == "2026-02-21T12:00:00Z"
|
|
148
|
-
|
|
149
|
-
|
|
150
83
|
def test_login_can_force_refresh_existing_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
151
84
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
152
85
|
save_user_token(server="https://softmax.com/api", token="old-token")
|
|
@@ -166,7 +99,7 @@ def test_login_returns_user_token_even_with_active_player_session(
|
|
|
166
99
|
tmp_path,
|
|
167
100
|
) -> None:
|
|
168
101
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
169
|
-
|
|
102
|
+
_activate_player("https://softmax.com/api", "player-token")
|
|
170
103
|
save_user_token(server="https://softmax.com/api", token="user-token")
|
|
171
104
|
|
|
172
105
|
called = {"interactive": False}
|
|
@@ -178,3 +111,71 @@ def test_login_returns_user_token_even_with_active_player_session(
|
|
|
178
111
|
assert softmax.login() == "user-token"
|
|
179
112
|
assert load_user_token(server="https://softmax.com/api") == "user-token"
|
|
180
113
|
assert called["interactive"] is False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_set_and_clear_active_player_session_preserves_user_token(
|
|
117
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
118
|
+
tmp_path,
|
|
119
|
+
) -> None:
|
|
120
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
121
|
+
server = "https://softmax.com/api"
|
|
122
|
+
save_user_token(server=server, token="user-token")
|
|
123
|
+
_activate_player(server, "player-token", player_id="ply_alpha")
|
|
124
|
+
|
|
125
|
+
assert load_player_session(server=server) == "player-token"
|
|
126
|
+
|
|
127
|
+
assert clear_active_player_session(server=server) is True
|
|
128
|
+
# Active pointer is gone, so the user token is what's current again.
|
|
129
|
+
assert load_player_session(server=server) is None
|
|
130
|
+
assert load_user_token(server=server) == "user-token"
|
|
131
|
+
# The cached player token survives for a fast re-activation.
|
|
132
|
+
cached = get_cached_player_session(server=server, player_id="ply_alpha")
|
|
133
|
+
assert cached is not None
|
|
134
|
+
assert cached.token == "player-token"
|
|
135
|
+
|
|
136
|
+
# Clearing again is a no-op.
|
|
137
|
+
assert clear_active_player_session(server=server) is False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_changing_user_token_drops_player_sessions(
|
|
141
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
142
|
+
tmp_path,
|
|
143
|
+
) -> None:
|
|
144
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
145
|
+
server = "https://softmax.com/api"
|
|
146
|
+
save_user_token(server=server, token="user-token")
|
|
147
|
+
_activate_player(server, "player-token", player_id="ply_alpha")
|
|
148
|
+
assert load_current_token(server=server) == "player-token"
|
|
149
|
+
|
|
150
|
+
# Re-saving the same token must NOT disturb the active player session.
|
|
151
|
+
save_user_token(server=server, token="user-token")
|
|
152
|
+
assert load_current_token(server=server) == "player-token"
|
|
153
|
+
|
|
154
|
+
# Switching the user credential invalidates the old user's player sessions,
|
|
155
|
+
# so commands act as the new user rather than the stale player.
|
|
156
|
+
save_user_token(server=server, token="new-user-token")
|
|
157
|
+
assert load_player_session(server=server) is None
|
|
158
|
+
assert get_cached_player_session(server=server, player_id="ply_alpha") is None
|
|
159
|
+
assert load_current_token(server=server) == "new-user-token"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_expired_player_session_falls_through_to_user_token(
|
|
163
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
164
|
+
tmp_path,
|
|
165
|
+
) -> None:
|
|
166
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
167
|
+
server = "https://softmax.com/api"
|
|
168
|
+
save_user_token(server=server, token="user-token")
|
|
169
|
+
|
|
170
|
+
# Activate a player with an already-expired session.
|
|
171
|
+
set_active_player_session(
|
|
172
|
+
server=server,
|
|
173
|
+
player_id="ply_alpha",
|
|
174
|
+
token="expired-player-token",
|
|
175
|
+
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# The active pointer is set, but load_player_session skips the expired token.
|
|
179
|
+
assert load_player_session(server=server) is None
|
|
180
|
+
# load_current_token falls through to the user token.
|
|
181
|
+
assert load_current_token(server=server) == "user-token"
|
softmax_cli-0.26.18/AGENTS.md
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# AGENTS.md — softmax-cli
|
|
2
|
-
|
|
3
|
-
The `softmax` CLI: authentication and account tools for Softmax/Observatory. Other packages (e.g. `coworld`) depend on
|
|
4
|
-
it for login/token handling and pin an exact version. Deps: `typer`, `rich`, `httpx`, plus `fastapi`/`uvicorn` for the
|
|
5
|
-
local browser-login callback server. The `cogames` optional extra mounts the cogames CLI as a `softmax cogames`
|
|
6
|
-
subcommand. Published to PyPI.
|
|
7
|
-
|
|
8
|
-
## CLI
|
|
9
|
-
|
|
10
|
-
Installs a `softmax` entrypoint (`softmax.cli:app`, Typer):
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
uv run softmax login # browser-based login (spins up a local callback server)
|
|
14
|
-
uv run softmax logout
|
|
15
|
-
uv run softmax status
|
|
16
|
-
uv run softmax get-login-url
|
|
17
|
-
uv run softmax get-token / set-token
|
|
18
|
-
uv run softmax cogames ... # only when installed with the `cogames` extra
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Tests
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
uv run metta pytest packages/softmax-cli/tests -v
|
|
25
|
-
uv run metta pytest --changed
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Tests cover auth/login, the Python API, and CLI plugin wiring; a `BUILD.bazel` exists under `tests/`.
|
|
29
|
-
|
|
30
|
-
## Lint
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
uv run metta lint --fix # ruff (also runs via the Edit/Write hook)
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## Source layout (`src/softmax/`)
|
|
37
|
-
|
|
38
|
-
- `cli.py` — the Typer app; mounts the optional `cogames` subapp via `add_typer`.
|
|
39
|
-
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
40
|
-
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
41
|
-
- `cogames.py` — the optional cogames subcommand wiring.
|
|
42
|
-
- `_console.py` — shared rich console helpers.
|
|
43
|
-
|
|
44
|
-
## Gotchas
|
|
45
|
-
|
|
46
|
-
- Versioned via `setuptools_scm` off `softmax-v*` git tags (`fallback_version = 0.0.0`).
|
|
47
|
-
- Downstream packages pin an exact `softmax-cli==X.Y.Z`; bumping the public auth API can break them — coordinate version
|
|
48
|
-
bumps with consumers like `coworld`.
|
softmax_cli-0.26.18/CLAUDE.md
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# AGENTS.md — softmax-cli
|
|
2
|
-
|
|
3
|
-
The `softmax` CLI: authentication and account tools for Softmax/Observatory. Other packages (e.g. `coworld`) depend on
|
|
4
|
-
it for login/token handling and pin an exact version. Deps: `typer`, `rich`, `httpx`, plus `fastapi`/`uvicorn` for the
|
|
5
|
-
local browser-login callback server. The `cogames` optional extra mounts the cogames CLI as a `softmax cogames`
|
|
6
|
-
subcommand. Published to PyPI.
|
|
7
|
-
|
|
8
|
-
## CLI
|
|
9
|
-
|
|
10
|
-
Installs a `softmax` entrypoint (`softmax.cli:app`, Typer):
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
uv run softmax login # browser-based login (spins up a local callback server)
|
|
14
|
-
uv run softmax logout
|
|
15
|
-
uv run softmax status
|
|
16
|
-
uv run softmax get-login-url
|
|
17
|
-
uv run softmax get-token / set-token
|
|
18
|
-
uv run softmax cogames ... # only when installed with the `cogames` extra
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Tests
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
uv run metta pytest packages/softmax-cli/tests -v
|
|
25
|
-
uv run metta pytest --changed
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Tests cover auth/login, the Python API, and CLI plugin wiring; a `BUILD.bazel` exists under `tests/`.
|
|
29
|
-
|
|
30
|
-
## Lint
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
uv run metta lint --fix # ruff (also runs via the Edit/Write hook)
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## Source layout (`src/softmax/`)
|
|
37
|
-
|
|
38
|
-
- `cli.py` — the Typer app; mounts the optional `cogames` subapp via `add_typer`.
|
|
39
|
-
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
40
|
-
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
41
|
-
- `cogames.py` — the optional cogames subcommand wiring.
|
|
42
|
-
- `_console.py` — shared rich console helpers.
|
|
43
|
-
|
|
44
|
-
## Gotchas
|
|
45
|
-
|
|
46
|
-
- Versioned via `setuptools_scm` off `softmax-v*` git tags (`fallback_version = 0.0.0`).
|
|
47
|
-
- Downstream packages pin an exact `softmax-cli==X.Y.Z`; bumping the public auth API can break them — coordinate version
|
|
48
|
-
bumps with consumers like `coworld`.
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import importlib
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from softmax.auth import DEFAULT_API_SERVER
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _get_tournament_client_class() -> type[Any]:
|
|
10
|
-
try:
|
|
11
|
-
tournament_client_module = importlib.import_module("cogames.cli.client")
|
|
12
|
-
except ModuleNotFoundError as exc:
|
|
13
|
-
raise RuntimeError(
|
|
14
|
-
"softmax.cogames requires the `cogames` package. Install `softmax-cli[cogames]` or `cogames`."
|
|
15
|
-
) from exc
|
|
16
|
-
return tournament_client_module.TournamentServerClient
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _create_client(*, token: str, server: str) -> Any:
|
|
20
|
-
client_class = _get_tournament_client_class()
|
|
21
|
-
return client_class(server_url=server, token=token)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class _PlayerAPI:
|
|
25
|
-
def list(
|
|
26
|
-
self,
|
|
27
|
-
token: str,
|
|
28
|
-
*,
|
|
29
|
-
server: str = DEFAULT_API_SERVER,
|
|
30
|
-
) -> list[Any]:
|
|
31
|
-
with _create_client(token=token, server=server) as client:
|
|
32
|
-
return client.list_players()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
player = _PlayerAPI()
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def login(
|
|
39
|
-
token: str,
|
|
40
|
-
player_id: str,
|
|
41
|
-
*,
|
|
42
|
-
server: str = DEFAULT_API_SERVER,
|
|
43
|
-
) -> str:
|
|
44
|
-
with _create_client(token=token, server=server) as client:
|
|
45
|
-
return client.login_player(player_id).token
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def login_response(
|
|
49
|
-
token: str,
|
|
50
|
-
player_id: str,
|
|
51
|
-
*,
|
|
52
|
-
server: str = DEFAULT_API_SERVER,
|
|
53
|
-
) -> Any:
|
|
54
|
-
with _create_client(token=token, server=server) as client:
|
|
55
|
-
return client.login_player(player_id)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
__all__ = ["player", "login", "login_response"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|