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.
Files changed (26) hide show
  1. softmax_cli-0.26.20/AGENTS.md +61 -0
  2. softmax_cli-0.26.20/CLAUDE.md +61 -0
  3. {softmax_cli-0.26.18/src/softmax_cli.egg-info → softmax_cli-0.26.20}/PKG-INFO +8 -7
  4. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/README.md +6 -6
  5. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/pyproject.toml +1 -1
  6. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/__init__.py +1 -8
  7. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/auth.py +86 -15
  8. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/cli.py +13 -0
  9. softmax_cli-0.26.20/src/softmax/players.py +138 -0
  10. {softmax_cli-0.26.18 → softmax_cli-0.26.20/src/softmax_cli.egg-info}/PKG-INFO +8 -7
  11. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/SOURCES.txt +2 -1
  12. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/requires.txt +1 -0
  13. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/tests/test_auth_login.py +12 -2
  14. softmax_cli-0.26.20/tests/test_player_cli.py +179 -0
  15. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/tests/test_python_api.py +89 -88
  16. softmax_cli-0.26.18/AGENTS.md +0 -48
  17. softmax_cli-0.26.18/CLAUDE.md +0 -48
  18. softmax_cli-0.26.18/src/softmax/cogames.py +0 -58
  19. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/BUILD.bazel +0 -0
  20. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/setup.cfg +0 -0
  21. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/_console.py +0 -0
  22. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax/perform_login.py +0 -0
  23. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
  24. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/entry_points.txt +0 -0
  25. {softmax_cli-0.26.18 → softmax_cli-0.26.20}/src/softmax_cli.egg-info/top_level.txt +0 -0
  26. {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.18
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, and account status. Other packages notably
24
- `coworld` — depend on it for auth. Installing with the `cogames` extra mounts the cogames CLI as a `softmax cogames`
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 # standalone
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 cogames ... # only with the `cogames` extra
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, and account status. Other packages notably
5
- `coworld` — depend on it for auth. Installing with the `cogames` extra mounts the cogames CLI as a `softmax cogames`
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 # standalone
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 cogames ... # only with the `cogames` extra
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
@@ -24,7 +24,7 @@ dependencies = [
24
24
  dynamic = ["version"]
25
25
 
26
26
  [project.optional-dependencies]
27
- test = ["pytest>=8.0"]
27
+ test = ["pytest>=8.0", "pytest-httpserver"]
28
28
 
29
29
  [project.scripts]
30
30
  softmax = "softmax.cli:app"
@@ -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
- def __getattr__(name: str) -> object:
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", {})[server] = token
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
- server = server.rstrip("/")
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
- def save_player_session(*, server: str, token: str) -> None:
108
- server = server.rstrip("/")
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.setdefault("player_sessions", {})[server] = token
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 _delete_player_session(*, server: str) -> bool:
115
- server = server.rstrip("/")
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.get("player_sessions", {})
118
- if server not in sessions:
182
+ sessions = _server_sessions(data, server)
183
+ if sessions.active is None:
119
184
  return False
120
- del sessions[server]
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 player_session if present, else user token."""
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
- deleted_session = _delete_player_session(server=server)
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.18
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, and account status. Other packages notably
24
- `coworld` — depend on it for auth. Installing with the `cogames` extra mounts the cogames CLI as a `softmax cogames`
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 # standalone
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 cogames ... # only with the `cogames` extra
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
@@ -8,3 +8,4 @@ uvicorn>=0.34.0
8
8
 
9
9
  [test]
10
10
  pytest>=8.0
11
+ pytest-httpserver
@@ -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, save_player_session, save_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
- save_player_session(server="https://softmax.com/api", token="player-session-token")
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 typing import Any, cast
4
+ from datetime import UTC, datetime, timedelta
6
5
 
7
6
  import pytest
8
7
 
9
8
  import softmax
10
- import softmax.cogames as softmax_cogames
11
- from softmax.auth import load_user_token, save_player_session, save_user_token
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
- save_player_session(server="https://softmax.com/api", token="active-only-token")
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
- save_player_session(server="https://softmax.com/api", token="player-token")
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"
@@ -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,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