softmax-cli 0.26.13__tar.gz → 0.26.15__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 (30) hide show
  1. softmax_cli-0.26.15/AGENTS.md +48 -0
  2. softmax_cli-0.26.15/BUILD.bazel +3 -0
  3. softmax_cli-0.26.15/CLAUDE.md +48 -0
  4. softmax_cli-0.26.15/PKG-INFO +54 -0
  5. softmax_cli-0.26.15/README.md +35 -0
  6. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/pyproject.toml +5 -4
  7. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax/__init__.py +5 -13
  8. softmax_cli-0.26.15/src/softmax/auth.py +166 -0
  9. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax/cli.py +35 -38
  10. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax/perform_login.py +28 -49
  11. softmax_cli-0.26.15/src/softmax_cli.egg-info/PKG-INFO +54 -0
  12. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax_cli.egg-info/SOURCES.txt +3 -2
  13. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax_cli.egg-info/requires.txt +6 -5
  14. softmax_cli-0.26.15/tests/BUILD.bazel +3 -0
  15. softmax_cli-0.26.15/tests/test_auth_login.py +228 -0
  16. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/tests/test_python_api.py +16 -17
  17. softmax_cli-0.26.13/BUILD.bazel +0 -19
  18. softmax_cli-0.26.13/PKG-INFO +0 -17
  19. softmax_cli-0.26.13/src/softmax/auth.py +0 -100
  20. softmax_cli-0.26.13/src/softmax/token_storage.py +0 -102
  21. softmax_cli-0.26.13/src/softmax_cli.egg-info/PKG-INFO +0 -17
  22. softmax_cli-0.26.13/tests/BUILD.bazel +0 -38
  23. softmax_cli-0.26.13/tests/test_auth_login.py +0 -344
  24. softmax_cli-0.26.13/tests/test_cli_plugins.py +0 -24
  25. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/setup.cfg +0 -0
  26. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax/_console.py +0 -0
  27. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax/cogames.py +0 -0
  28. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
  29. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax_cli.egg-info/entry_points.txt +0 -0
  30. {softmax_cli-0.26.13 → softmax_cli-0.26.15}/src/softmax_cli.egg-info/top_level.txt +0 -0
@@ -0,0 +1,48 @@
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`.
@@ -0,0 +1,3 @@
1
+ load("//bazel:pyproject.bzl", "pyproject_library")
2
+
3
+ pyproject_library(typecheck = False)
@@ -0,0 +1,48 @@
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`.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: softmax-cli
3
+ Version: 0.26.15
4
+ Summary: Softmax CLI — authentication and account tools
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Programming Language :: Python :: 3.11
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Requires-Python: <3.13,>=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: fastapi>=0.115.0
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: pydantic>=2.0
13
+ Requires-Dist: pyyaml>=6.0.2
14
+ Requires-Dist: rich>=13.7.0
15
+ Requires-Dist: typer>=0.19.2
16
+ Requires-Dist: uvicorn>=0.34.0
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8.0; extra == "test"
19
+
20
+ # softmax-cli
21
+
22
+ 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.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ uv tool install softmax-cli # standalone
31
+ uv tool install "softmax-cli[cogames]" # with the cogames subcommand
32
+ ```
33
+
34
+ Within the metta workspace it is available via `uv sync`.
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ uv run softmax login # log in via the browser
40
+ uv run softmax status # show current auth status
41
+ uv run softmax logout
42
+ uv run softmax get-token # print the stored token
43
+ uv run softmax set-token # store a token manually
44
+ uv run softmax cogames ... # only with the `cogames` extra
45
+ ```
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ uv run metta pytest packages/softmax-cli/tests -v # run tests
51
+ uv run metta lint --fix # lint/format
52
+ ```
53
+
54
+ See [AGENTS.md](AGENTS.md) for the source layout and versioning/compatibility notes.
@@ -0,0 +1,35 @@
1
+ # softmax-cli
2
+
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.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ uv tool install softmax-cli # standalone
12
+ uv tool install "softmax-cli[cogames]" # with the cogames subcommand
13
+ ```
14
+
15
+ Within the metta workspace it is available via `uv sync`.
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ uv run softmax login # log in via the browser
21
+ uv run softmax status # show current auth status
22
+ uv run softmax logout
23
+ uv run softmax get-token # print the stored token
24
+ uv run softmax set-token # store a token manually
25
+ uv run softmax cogames ... # only with the `cogames` extra
26
+ ```
27
+
28
+ ## Development
29
+
30
+ ```bash
31
+ uv run metta pytest packages/softmax-cli/tests -v # run tests
32
+ uv run metta lint --fix # lint/format
33
+ ```
34
+
35
+ See [AGENTS.md](AGENTS.md) for the source layout and versioning/compatibility notes.
@@ -13,17 +13,18 @@ classifiers = [
13
13
  "Programming Language :: Python :: 3.12",
14
14
  ]
15
15
  dependencies = [
16
- "typer>=0.19.2",
17
- "rich>=13.7.0",
18
16
  "fastapi>=0.115.0",
19
- "uvicorn>=0.34.0",
20
17
  "httpx>=0.28.1",
18
+ "pydantic>=2.0",
21
19
  "pyyaml>=6.0.2",
20
+ "rich>=13.7.0",
21
+ "typer>=0.19.2",
22
+ "uvicorn>=0.34.0",
22
23
  ]
23
24
  dynamic = ["version"]
24
25
 
25
26
  [project.optional-dependencies]
26
- cogames = ["cogames"]
27
+ test = ["pytest>=8.0"]
27
28
 
28
29
  [project.scripts]
29
30
  softmax = "softmax.cli:app"
@@ -4,13 +4,8 @@ import importlib
4
4
  import sys
5
5
  from pkgutil import extend_path
6
6
 
7
- from softmax.auth import (
8
- DEFAULT_API_SERVER,
9
- load_cogames_user_token,
10
- save_cogames_active_token,
11
- )
7
+ from softmax.auth import DEFAULT_API_SERVER, load_user_token
12
8
  from softmax.perform_login import do_interactive_login_for_token
13
- from softmax.token_storage import TokenKind
14
9
 
15
10
  __path__ = extend_path(__path__, __name__)
16
11
 
@@ -21,9 +16,8 @@ def login(
21
16
  force: bool = False,
22
17
  open_browser: bool = True,
23
18
  ) -> str:
24
- token = None if force else load_cogames_user_token(api_server=api_server)
19
+ token = None if force else load_user_token(server=api_server)
25
20
  if token is not None:
26
- save_cogames_active_token(api_server=api_server, token=token)
27
21
  return token
28
22
 
29
23
  if not sys.stdin.isatty():
@@ -34,20 +28,18 @@ def login(
34
28
 
35
29
  do_interactive_login_for_token(
36
30
  api_server=api_server,
37
- token_kind=TokenKind.COGAMES_USER,
38
31
  agent_hint=(
39
32
  "If you are a coding agent, ask your human to open the URL below and give you "
40
- "the auth token. Then paste the token into this window or run:\n"
33
+ "the auth code. Then paste the code into this window or run:\n"
41
34
  "\n"
42
- "softmax set-token '<TOKEN>'"
35
+ "softmax exchange-code '<CODE>'"
43
36
  ),
44
37
  open_browser=open_browser,
45
38
  )
46
39
 
47
- token = load_cogames_user_token(api_server=api_server)
40
+ token = load_user_token(server=api_server)
48
41
  if token is None:
49
42
  raise RuntimeError(f"Interactive login did not save a token for {api_server}")
50
- save_cogames_active_token(api_server=api_server, token=token)
51
43
  return token
52
44
 
53
45
 
@@ -0,0 +1,166 @@
1
+ """Auth helpers for the softmax CLI: token storage, browser login URL, whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from urllib.parse import urlencode, urlsplit, urlunsplit
8
+
9
+ import httpx
10
+ import yaml
11
+ from pydantic import BaseModel, Field
12
+
13
+ DEFAULT_API_SERVER = "https://softmax.com/api"
14
+
15
+ CREDENTIALS_FILE = "credentials.yaml"
16
+
17
+
18
+ def get_api_server() -> str:
19
+ return os.environ.get("COGAMES_API_URL", DEFAULT_API_SERVER)
20
+
21
+
22
+ # --- on-disk storage ---
23
+
24
+
25
+ def _config_dir() -> Path:
26
+ config_dir = Path.home() / ".softmax"
27
+ config_dir.mkdir(parents=True, exist_ok=True)
28
+ return config_dir
29
+
30
+
31
+ def _credentials_path() -> Path:
32
+ return _config_dir() / CREDENTIALS_FILE
33
+
34
+
35
+ def _load_data() -> dict:
36
+ cred_path = _credentials_path()
37
+ if not cred_path.exists():
38
+ return {"tokens": {}, "player_sessions": {}}
39
+
40
+ with open(cred_path, "r") as f:
41
+ return yaml.safe_load(f) or {"tokens": {}, "player_sessions": {}}
42
+
43
+
44
+ def _save_data(data: dict) -> None:
45
+ cred_path = _credentials_path()
46
+ with open(cred_path, "w") as f:
47
+ yaml.safe_dump(data, f, default_flow_style=False)
48
+ os.chmod(cred_path, 0o600)
49
+
50
+
51
+ # --- public token API ---
52
+
53
+
54
+ def load_user_token(*, server: str) -> str | None:
55
+ server = server.rstrip("/")
56
+ tokens = _load_data().get("tokens", {})
57
+ token = tokens.get(server)
58
+ return token if isinstance(token, str) else None
59
+
60
+
61
+ def save_user_token(*, server: str, token: str) -> None:
62
+ server = server.rstrip("/")
63
+ data = _load_data()
64
+ data.setdefault("tokens", {})[server] = token
65
+ _save_data(data)
66
+
67
+
68
+ def _delete_user_token(*, server: str) -> bool:
69
+ server = server.rstrip("/")
70
+ data = _load_data()
71
+ tokens = data.get("tokens", {})
72
+ if server not in tokens:
73
+ return False
74
+ del tokens[server]
75
+ _save_data(data)
76
+ return True
77
+
78
+
79
+ def load_player_session(*, server: str) -> str | None:
80
+ server = server.rstrip("/")
81
+ sessions = _load_data().get("player_sessions", {})
82
+ token = sessions.get(server)
83
+ return token if isinstance(token, str) else None
84
+
85
+
86
+ def save_player_session(*, server: str, token: str) -> None:
87
+ server = server.rstrip("/")
88
+ data = _load_data()
89
+ data.setdefault("player_sessions", {})[server] = token
90
+ _save_data(data)
91
+
92
+
93
+ def _delete_player_session(*, server: str) -> bool:
94
+ server = server.rstrip("/")
95
+ data = _load_data()
96
+ sessions = data.get("player_sessions", {})
97
+ if server not in sessions:
98
+ return False
99
+ del sessions[server]
100
+ _save_data(data)
101
+ return True
102
+
103
+
104
+ def load_current_token(*, server: str) -> str | None:
105
+ """Returns player_session if present, else user token."""
106
+ return load_player_session(server=server) or load_user_token(server=server)
107
+
108
+
109
+ def has_saved_token(*, server: str) -> bool:
110
+ return load_current_token(server=server) is not None
111
+
112
+
113
+ def delete_all_tokens(*, server: str) -> bool:
114
+ deleted_token = _delete_user_token(server=server)
115
+ deleted_session = _delete_player_session(server=server)
116
+ return deleted_token or deleted_session
117
+
118
+
119
+ # --- HTTP / URL helpers ---
120
+
121
+
122
+ class WhoAmIResponse(BaseModel):
123
+ user_email: str
124
+ is_softmax_team_member: bool = False
125
+ is_softmax_admin: bool = False
126
+ subject_type: str = "user"
127
+ subject_id: str | None = None
128
+ owner_user_id: str | None = None
129
+ scopes: list[str] = Field(default_factory=list)
130
+
131
+
132
+ def build_browser_login_url(api_server: str, *, callback_url: str | None = None) -> str:
133
+ """Build the hosted browser sign-in URL for CLI login."""
134
+ params: dict[str, str] = {}
135
+ if callback_url:
136
+ params["callback"] = callback_url
137
+
138
+ query = urlencode(params)
139
+ parsed = urlsplit(api_server)
140
+ # Code-exchange flow lives at /cli-auth; the legacy /cli-login route is
141
+ # preserved untouched for published softmax-cli <= 0.26.14 (pinned by
142
+ # coworld[auth]) until those releases age out.
143
+ browser_path = parsed.path.rstrip("/").removesuffix("/api") + "/cli-auth"
144
+ return urlunsplit((parsed.scheme, parsed.netloc, browser_path, query, ""))
145
+
146
+
147
+ def fetch_cogames_whoami(*, api_server: str | None = None, token: str) -> WhoAmIResponse:
148
+ server = api_server or DEFAULT_API_SERVER
149
+ response = httpx.get(
150
+ f"{server.rstrip('/')}/observatory/whoami",
151
+ headers={"Authorization": f"Bearer {token}"},
152
+ timeout=10.0,
153
+ )
154
+ response.raise_for_status()
155
+ return WhoAmIResponse.model_validate(response.json())
156
+
157
+
158
+ def exchange_auth_code(*, api_server: str, code: str) -> str:
159
+ """Exchange a one-time auth code for a real credential token."""
160
+ response = httpx.post(
161
+ f"{api_server.rstrip('/')}/observatory/credentials/auth-codes/exchange",
162
+ json={"code": code},
163
+ timeout=10.0,
164
+ )
165
+ response.raise_for_status()
166
+ return response.json()["token"]
@@ -1,7 +1,5 @@
1
1
  """softmax CLI — authentication and account tools."""
2
2
 
3
- import importlib
4
- import importlib.util
5
3
  import sys
6
4
 
7
5
  import httpx
@@ -12,16 +10,15 @@ from softmax._console import console
12
10
  from softmax.auth import (
13
11
  DEFAULT_API_SERVER,
14
12
  build_browser_login_url,
15
- delete_cogames_tokens,
13
+ delete_all_tokens,
14
+ exchange_auth_code,
16
15
  fetch_cogames_whoami,
17
16
  get_api_server,
18
- load_cogames_user_token,
19
- load_current_cogames_token,
20
- save_cogames_active_token,
21
- save_cogames_user_token,
17
+ load_current_token,
18
+ load_user_token,
19
+ save_user_token,
22
20
  )
23
21
  from softmax.perform_login import do_interactive_login_for_token
24
- from softmax.token_storage import TokenKind
25
22
 
26
23
  app = typer.Typer(
27
24
  help="Softmax CLI — authentication and account tools",
@@ -31,20 +28,10 @@ app = typer.Typer(
31
28
  )
32
29
 
33
30
 
34
- def _register_optional_apps() -> None:
35
- if importlib.util.find_spec("cogames") is None:
36
- return
37
- if importlib.util.find_spec("cogames.softmax_cli") is None:
38
- return
39
-
40
- cogames_cli = importlib.import_module("cogames.softmax_cli")
41
- app.add_typer(cogames_cli.app, name="cogames", rich_help_panel="Local Games")
42
-
43
-
44
- def _build_manual_set_token_command(server: str | None = None) -> str:
31
+ def _build_manual_exchange_command(server: str | None = None) -> str:
45
32
  if server:
46
- return f"softmax set-token --server '{server}' '<TOKEN>'"
47
- return "softmax set-token '<TOKEN>'"
33
+ return f"softmax exchange-code --server '{server}' '<CODE>'"
34
+ return "softmax exchange-code '<CODE>'"
48
35
 
49
36
 
50
37
  def _print_non_tty_login_instructions(api_server: str | None = None) -> None:
@@ -57,14 +44,14 @@ def _print_non_tty_login_instructions(api_server: str | None = None) -> None:
57
44
  console.print()
58
45
  console.print(" ", auth_url)
59
46
  console.print()
60
- console.print("Copy the auth token from the browser, then run:", style="yellow")
47
+ console.print("Copy the auth code from the browser, then run:", style="yellow")
61
48
  console.print()
62
- console.print(" ", _build_manual_set_token_command(resolved if is_custom else None))
49
+ console.print(" ", _build_manual_exchange_command(resolved if is_custom else None))
63
50
  console.print()
64
51
  console.print(
65
52
  Panel(
66
- "If you are a coding agent, ask your human to open the URL above and give you the resulting auth token. "
67
- "Then run the set-token command above.",
53
+ "If you are a coding agent, ask your human to open the URL above and give you "
54
+ "the auth code. Then run the exchange-code command above.",
68
55
  title="🤖 Agent Hint",
69
56
  border_style="cyan",
70
57
  )
@@ -96,7 +83,7 @@ def login_cmd(
96
83
  from urllib.parse import urlparse # noqa: PLC0415
97
84
 
98
85
  api_server = server or get_api_server()
99
- user_token = None if force else load_cogames_user_token(api_server=api_server)
86
+ user_token = None if force else load_user_token(server=api_server)
100
87
  if user_token is not None:
101
88
  try:
102
89
  whoami = fetch_cogames_whoami(api_server=api_server, token=user_token)
@@ -113,7 +100,6 @@ def login_cmd(
113
100
  console.print("Could not reach server to verify token. Proceeding with saved token.", style="yellow")
114
101
 
115
102
  if user_token is not None:
116
- save_cogames_active_token(api_server=api_server, token=user_token)
117
103
  console.print(f"Already authenticated with {urlparse(api_server).hostname}", style="green")
118
104
  return
119
105
 
@@ -124,12 +110,11 @@ def login_cmd(
124
110
  try:
125
111
  do_interactive_login_for_token(
126
112
  api_server=api_server,
127
- token_kind=TokenKind.COGAMES_USER,
128
113
  agent_hint=(
129
114
  "If you are a coding agent, ask your human to open the URL below and give you "
130
- "the auth token. Then paste the token into this window or run:\n"
115
+ "the auth code. Then paste the code into this window or run:\n"
131
116
  "\n"
132
- f"{_build_manual_set_token_command(server)}"
117
+ f"{_build_manual_exchange_command(server)}"
133
118
  ),
134
119
  open_browser=not no_browser,
135
120
  )
@@ -139,9 +124,6 @@ def login_cmd(
139
124
  console.print("Authentication failed.", style="red")
140
125
  raise typer.Exit(1) from e
141
126
 
142
- user_token = load_cogames_user_token(api_server=api_server)
143
- assert user_token is not None
144
- save_cogames_active_token(api_server=api_server, token=user_token)
145
127
  console.print("Authentication successful.", style="green")
146
128
 
147
129
 
@@ -157,7 +139,7 @@ def logout_cmd(
157
139
  ) -> None:
158
140
  """Remove saved authentication token."""
159
141
  api_server = server or get_api_server()
160
- if delete_cogames_tokens(api_server=api_server):
142
+ if delete_all_tokens(server=api_server):
161
143
  console.print("Logged out.", style="green")
162
144
  else:
163
145
  console.print("No token found — already logged out.", style="yellow")
@@ -189,7 +171,7 @@ def status_cmd(
189
171
  ) -> None:
190
172
  """Check authentication status via /whoami."""
191
173
  api_server = server or get_api_server()
192
- token = load_current_cogames_token(api_server=api_server)
174
+ token = load_current_token(server=api_server)
193
175
  if not token:
194
176
  console.print("[red]Not authenticated.[/red] Run [cyan]softmax login[/cyan] first.")
195
177
  raise typer.Exit(1)
@@ -217,7 +199,7 @@ def get_token_cmd(
217
199
  ) -> None:
218
200
  """Print the saved token to stdout (for scripting)."""
219
201
  api_server = server or get_api_server()
220
- token = load_current_cogames_token(api_server=api_server)
202
+ token = load_current_token(server=api_server)
221
203
  if not token:
222
204
  console.print("[red]No token found.[/red] Run [cyan]softmax login[/cyan] first.", style="bold")
223
205
  raise typer.Exit(1)
@@ -237,8 +219,23 @@ def set_token_cmd(
237
219
  ) -> None:
238
220
  """Manually set a token (for CI or headless environments)."""
239
221
  api_server = server or get_api_server()
240
- save_cogames_user_token(api_server=api_server, token=token)
222
+ save_user_token(server=api_server, token=token)
241
223
  print(f"\nToken saved for {api_server}")
242
224
 
243
225
 
244
- _register_optional_apps()
226
+ @app.command(name="exchange-code")
227
+ def exchange_code_cmd(
228
+ code: str = typer.Argument(help="One-time auth code from browser login"),
229
+ server: str | None = typer.Option(
230
+ None,
231
+ "--server",
232
+ "-s",
233
+ metavar="URL",
234
+ help="API server URL.",
235
+ ),
236
+ ) -> None:
237
+ """Exchange a one-time auth code for a credential."""
238
+ api_server = server or get_api_server()
239
+ token = exchange_auth_code(api_server=api_server, code=code)
240
+ save_user_token(server=api_server, token=token)
241
+ print(f"\nToken saved for {api_server}")