softmax-cli 0.1.0__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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: softmax-cli
3
+ Version: 0.1.0
4
+ Summary: Softmax CLI — authentication and account tools
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Programming Language :: Python :: 3.12
7
+ Requires-Python: <3.13,>=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer>=0.19.2
10
+ Requires-Dist: rich>=13.7.0
11
+ Requires-Dist: fastapi>=0.115.0
12
+ Requires-Dist: uvicorn>=0.34.0
13
+ Requires-Dist: httpx>=0.28.1
14
+ Requires-Dist: pyyaml>=6.0.2
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools==80.9.0", "wheel==0.45.1", "setuptools_scm==8.1.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "softmax-cli"
7
+ description = "Softmax CLI — authentication and account tools"
8
+ readme = "README.md"
9
+ requires-python = ">=3.12,<3.13"
10
+ classifiers = ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12"]
11
+ dependencies = [
12
+ "typer>=0.19.2",
13
+ "rich>=13.7.0",
14
+ "fastapi>=0.115.0",
15
+ "uvicorn>=0.34.0",
16
+ "httpx>=0.28.1",
17
+ "pyyaml>=6.0.2",
18
+ ]
19
+ dynamic = ["version"]
20
+
21
+ [project.scripts]
22
+ softmax = "softmax.cli:app"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.setuptools_scm]
28
+ tag_regex = "^softmax-v(?P<version>\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?)$"
29
+ version_scheme = "no-guess-dev"
30
+ local_scheme = "no-local-version"
31
+ root = "../.."
32
+ fallback_version = "0.0.0"
33
+ git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "softmax-v*"]
34
+
35
+ [tool.pytest.ini_options]
36
+ pythonpath = ["src"]
37
+ testpaths = ["tests"]
38
+
39
+ [tool.ruff]
40
+ extend = "../../.ruff.toml"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console()
@@ -0,0 +1,40 @@
1
+ """Token storage and browser URL helpers for CLI auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlencode, urlsplit, urlunsplit
6
+
7
+ from softmax.token_storage import TokenKind
8
+ from softmax.token_storage import delete_token as delete_stored_token
9
+ from softmax.token_storage import load_token as load_saved_token
10
+ from softmax.token_storage import save_token as save_stored_token
11
+
12
+ DEFAULT_COGAMES_SERVER = "https://softmax.com/api"
13
+
14
+
15
+ def build_browser_login_url(login_server: str, *, callback_url: str | None = None) -> str:
16
+ """Build the hosted browser sign-in URL for CLI login."""
17
+ params: dict[str, str] = {}
18
+ if callback_url:
19
+ params["callback"] = callback_url
20
+
21
+ query = urlencode(params)
22
+ parsed = urlsplit(login_server)
23
+ browser_path = parsed.path.rstrip("/").removesuffix("/api") + "/cli-login"
24
+ return urlunsplit((parsed.scheme, parsed.netloc, browser_path, query, ""))
25
+
26
+
27
+ def load_token(*, token_kind: TokenKind, server: str) -> str | None:
28
+ return load_saved_token(token_kind=token_kind, server=server)
29
+
30
+
31
+ def has_saved_token(*, token_kind: TokenKind, server: str) -> bool:
32
+ return load_token(token_kind=token_kind, server=server) is not None
33
+
34
+
35
+ def save_token(*, token_kind: TokenKind, server: str, token: str) -> None:
36
+ save_stored_token(token_kind=token_kind, server=server, token=token)
37
+
38
+
39
+ def delete_token(*, token_kind: TokenKind, server: str) -> bool:
40
+ return delete_stored_token(token_kind=token_kind, server=server)
@@ -0,0 +1,194 @@
1
+ """softmax CLI — authentication and account tools."""
2
+
3
+ import sys
4
+
5
+ import httpx
6
+ import typer
7
+ from rich.panel import Panel
8
+
9
+ from softmax._console import console
10
+ from softmax.auth import (
11
+ DEFAULT_COGAMES_SERVER,
12
+ build_browser_login_url,
13
+ delete_token,
14
+ has_saved_token,
15
+ load_token,
16
+ save_token,
17
+ )
18
+ from softmax.perform_login import do_interactive_login_for_token
19
+ from softmax.token_storage import TokenKind
20
+
21
+ app = typer.Typer(
22
+ help="Softmax CLI — authentication and account tools",
23
+ context_settings={"help_option_names": ["-h", "--help"]},
24
+ no_args_is_help=True,
25
+ rich_markup_mode="rich",
26
+ )
27
+
28
+
29
+ def _build_manual_set_token_command(*, login_server: str) -> str:
30
+ command = "softmax set-token '<TOKEN>'"
31
+ if login_server != DEFAULT_COGAMES_SERVER:
32
+ command += f" --login-server '{login_server}'"
33
+ return command
34
+
35
+
36
+ def _print_non_tty_login_instructions(*, login_server: str) -> None:
37
+ auth_url = build_browser_login_url(login_server)
38
+ console.print("Interactive login requires a TTY.", style="red")
39
+ console.print()
40
+ console.print("Open this URL in any browser to sign in:", style="yellow")
41
+ console.print()
42
+ console.print(" ", auth_url)
43
+ console.print()
44
+ console.print("Copy the auth token from the browser, then run:", style="yellow")
45
+ console.print()
46
+ console.print(" ", _build_manual_set_token_command(login_server=login_server))
47
+ console.print()
48
+ console.print(
49
+ Panel(
50
+ "If you are a coding agent, ask your human to open the URL above and give you the resulting auth token. "
51
+ "Then run the set-token command above.",
52
+ title="🤖 Agent Hint",
53
+ border_style="cyan",
54
+ )
55
+ )
56
+
57
+
58
+ @app.command(name="login")
59
+ def login_cmd(
60
+ login_server: str = typer.Option(
61
+ DEFAULT_COGAMES_SERVER,
62
+ "--login-server",
63
+ metavar="URL",
64
+ help="Authentication server URL",
65
+ ),
66
+ no_browser: bool = typer.Option(
67
+ False,
68
+ "--no-browser",
69
+ help="Skip opening browser automatically.",
70
+ ),
71
+ force: bool = typer.Option(
72
+ False,
73
+ "--force",
74
+ "-f",
75
+ help="Re-authenticate even if already logged in",
76
+ ),
77
+ ) -> None:
78
+ """Sign in to Softmax."""
79
+ from urllib.parse import urlparse # noqa: PLC0415
80
+
81
+ if has_saved_token(token_kind=TokenKind.COGAMES, server=login_server) and not force:
82
+ console.print(f"Already authenticated with {urlparse(login_server).hostname}", style="green")
83
+ return
84
+
85
+ if not sys.stdin.isatty():
86
+ _print_non_tty_login_instructions(login_server=login_server)
87
+ raise typer.Exit(1)
88
+
89
+ try:
90
+ do_interactive_login_for_token(
91
+ login_server=login_server,
92
+ server_to_save_token_under=login_server,
93
+ token_kind=TokenKind.COGAMES,
94
+ agent_hint=(
95
+ "If you are a coding agent, ask your human to open the URL below and give you "
96
+ "the auth token. Then paste the token into this window or run:\n"
97
+ "\n"
98
+ f"{_build_manual_set_token_command(login_server=login_server)}"
99
+ ),
100
+ open_browser=not no_browser,
101
+ )
102
+ except Exception as e:
103
+ console.print(f"Error: {e}")
104
+ console.print()
105
+ console.print("Authentication failed.", style="red")
106
+ raise typer.Exit(1) from e
107
+
108
+ console.print("Authentication successful.", style="green")
109
+
110
+
111
+ @app.command(name="logout")
112
+ def logout_cmd(
113
+ login_server: str = typer.Option(
114
+ DEFAULT_COGAMES_SERVER,
115
+ "--login-server",
116
+ metavar="URL",
117
+ help="Authentication server URL",
118
+ ),
119
+ ) -> None:
120
+ """Remove saved authentication token."""
121
+ if delete_token(token_kind=TokenKind.COGAMES, server=login_server):
122
+ console.print("Logged out.", style="green")
123
+ else:
124
+ console.print("No token found — already logged out.", style="yellow")
125
+
126
+
127
+ @app.command(name="get-login-url")
128
+ def get_login_url_cmd(
129
+ login_server: str = typer.Option(
130
+ DEFAULT_COGAMES_SERVER,
131
+ "--login-server",
132
+ metavar="URL",
133
+ help="Authentication server URL",
134
+ ),
135
+ ) -> None:
136
+ """Print a browser sign-in URL for manual login."""
137
+ print(build_browser_login_url(login_server))
138
+
139
+
140
+ @app.command(name="status")
141
+ def status_cmd(
142
+ login_server: str = typer.Option(
143
+ DEFAULT_COGAMES_SERVER,
144
+ "--login-server",
145
+ metavar="URL",
146
+ help="Authentication server URL",
147
+ ),
148
+ ) -> None:
149
+ """Check authentication status via /whoami."""
150
+ token = load_token(token_kind=TokenKind.COGAMES, server=login_server)
151
+ if not token:
152
+ console.print("[red]Not authenticated.[/red] Run [cyan]softmax login[/cyan] first.")
153
+ raise typer.Exit(1)
154
+
155
+ response = httpx.get(
156
+ f"{login_server.rstrip('/')}/whoami",
157
+ headers={"Authorization": f"Bearer {token}"},
158
+ timeout=10.0,
159
+ )
160
+ response.raise_for_status()
161
+ email = response.json().get("user_email", "unknown")
162
+ console.print(f"[green]Authenticated as {email}[/green]")
163
+
164
+
165
+ @app.command(name="get-token")
166
+ def get_token_cmd(
167
+ login_server: str = typer.Option(
168
+ DEFAULT_COGAMES_SERVER,
169
+ "--login-server",
170
+ metavar="URL",
171
+ help="Authentication server URL",
172
+ ),
173
+ ) -> None:
174
+ """Print the saved token to stdout (for scripting)."""
175
+ token = load_token(token_kind=TokenKind.COGAMES, server=login_server)
176
+ if not token:
177
+ console.print("[red]No token found.[/red] Run [cyan]softmax login[/cyan] first.", style="bold")
178
+ raise typer.Exit(1)
179
+ print(token)
180
+
181
+
182
+ @app.command(name="set-token")
183
+ def set_token_cmd(
184
+ token: str = typer.Argument(help="Bearer token to save"),
185
+ login_server: str = typer.Option(
186
+ DEFAULT_COGAMES_SERVER,
187
+ "--login-server",
188
+ metavar="URL",
189
+ help="Authentication server URL",
190
+ ),
191
+ ) -> None:
192
+ """Manually set a token (for CI or headless environments)."""
193
+ save_token(token_kind=TokenKind.COGAMES, token=token, server=login_server)
194
+ print(f"\nToken saved for {login_server}")
@@ -0,0 +1,395 @@
1
+ """Interactive CLI login flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import html
7
+ import socket
8
+ import sys
9
+ import threading
10
+ import time
11
+ import webbrowser
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from typing import Literal, Sequence
15
+
16
+ import httpx
17
+ import uvicorn
18
+ from fastapi import FastAPI, Request
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.responses import HTMLResponse
21
+ from rich.panel import Panel
22
+
23
+ from softmax._console import console
24
+ from softmax.auth import build_browser_login_url, save_token
25
+ from softmax.token_storage import TokenKind
26
+
27
+
28
+ @dataclass
29
+ class _CLIAuthSession:
30
+ token: str | None = None
31
+ error: str | None = None
32
+ auth_completed: threading.Event = field(default_factory=threading.Event)
33
+ completion_lock: threading.Lock = field(default_factory=threading.Lock)
34
+
35
+
36
+ def _render_html(
37
+ *,
38
+ title: str,
39
+ headline: str,
40
+ message_lines: Sequence[str],
41
+ status: Literal["success", "error"],
42
+ auto_close_seconds: int | None = None,
43
+ extra_html: str = "",
44
+ ) -> str:
45
+ icon = "&#10003;" if status == "success" else "&#9888;"
46
+ escaped_title = html.escape(title)
47
+ escaped_headline = html.escape(headline)
48
+ messages = "".join(f"<p class='smx-auth__message'>{html.escape(line)}</p>" for line in message_lines)
49
+ current_year = datetime.now().year
50
+ auto_close_script = ""
51
+ if auto_close_seconds is not None:
52
+ auto_close_script = f"""
53
+ <script>
54
+ window.setTimeout(function () {{
55
+ try {{
56
+ window.close();
57
+ }} catch (err) {{
58
+ console.debug("Auto-close suppressed", err);
59
+ }}
60
+ }}, {int(auto_close_seconds * 1000)});
61
+ </script>"""
62
+
63
+ return f"""<!DOCTYPE html>
64
+ <html lang="en">
65
+ <head>
66
+ <meta charset="utf-8" />
67
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
68
+ <title>{escaped_title}</title>
69
+ <link rel="stylesheet" href="https://softmax.com/Assets/softmax.css" />
70
+ <style>
71
+ :root {{
72
+ color-scheme: light;
73
+ }}
74
+ * {{
75
+ box-sizing: border-box;
76
+ }}
77
+ body.smx-auth-page {{
78
+ margin: 0;
79
+ min-height: 100vh;
80
+ background-color: #fffdf4;
81
+ color: #0E2758;
82
+ font-family: "ABC Marfa Variable", "Roboto", -apple-system, BlinkMacSystemFont, sans-serif;
83
+ display: flex;
84
+ flex-direction: column;
85
+ align-items: center;
86
+ justify-content: center;
87
+ padding: clamp(2.4rem, 8vw, 4rem) 1.5rem;
88
+ overflow: hidden;
89
+ text-rendering: optimizeLegibility;
90
+ }}
91
+ .smx-auth-card {{
92
+ width: min(560px, 100%);
93
+ background: rgba(255, 254, 248, 0.95);
94
+ border-radius: 24px;
95
+ border: 1px solid rgba(14, 39, 88, 0.12);
96
+ box-shadow: 0 32px 60px rgba(14, 39, 88, 0.12);
97
+ padding: clamp(2rem, 6vw, 3.25rem);
98
+ text-align: center;
99
+ }}
100
+ .smx-auth-card--success .smx-auth-icon {{
101
+ background: rgba(26, 107, 63, 0.16);
102
+ color: #195C38;
103
+ }}
104
+ .smx-auth-card--error .smx-auth-icon {{
105
+ background: rgba(176, 46, 38, 0.16);
106
+ color: #952F2B;
107
+ }}
108
+ .smx-auth-icon {{
109
+ height: 76px;
110
+ width: 76px;
111
+ border-radius: 50%;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ font-size: 2.5rem;
116
+ margin: 0 auto 20px;
117
+ border: 1px solid rgba(14, 39, 88, 0.12);
118
+ }}
119
+ .smx-auth-headline {{
120
+ margin: 0 0 12px;
121
+ font-size: clamp(1.8rem, 5vw, 2.35rem);
122
+ font-weight: 600;
123
+ letter-spacing: -0.01em;
124
+ }}
125
+ .smx-auth__message {{
126
+ margin: 0 0 12px;
127
+ font-size: 1.02rem;
128
+ line-height: 1.6;
129
+ color: rgba(14, 39, 88, 0.72);
130
+ }}
131
+ .smx-auth__body {{
132
+ display: grid;
133
+ gap: 8px;
134
+ }}
135
+ .smx-auth__actions {{
136
+ margin-top: 32px;
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 14px;
140
+ }}
141
+ .smx-auth-button {{
142
+ appearance: none;
143
+ border-radius: 999px;
144
+ border: 2px solid #0E2758;
145
+ background: #0E2758;
146
+ color: #fffdf4;
147
+ cursor: pointer;
148
+ padding: 0.9rem 1.8rem;
149
+ font-size: 0.95rem;
150
+ font-family: "Marfa Mono", "Courier New", monospace;
151
+ font-weight: 600;
152
+ letter-spacing: 0.08em;
153
+ text-transform: uppercase;
154
+ transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
155
+ }}
156
+ .smx-auth-button:hover {{
157
+ transform: translateY(-1px);
158
+ background: #1a3875;
159
+ border-color: #1a3875;
160
+ box-shadow: 0 14px 28px rgba(26, 56, 117, 0.18);
161
+ }}
162
+ .smx-auth-button:active {{
163
+ transform: translateY(0);
164
+ box-shadow: 0 8px 16px rgba(14, 39, 88, 0.18);
165
+ }}
166
+ .smx-auth-footnote {{
167
+ margin-top: 28px;
168
+ font-size: 0.85rem;
169
+ color: rgba(14, 39, 88, 0.55);
170
+ }}
171
+ @media (max-width: 540px) {{
172
+ .smx-auth-card {{
173
+ padding: 2.4rem 1.8rem;
174
+ }}
175
+ }}
176
+ </style>
177
+ </head>
178
+ <body class="smx-auth-page">
179
+ <main class="smx-auth-card smx-auth-card--{status}" role="dialog" aria-live="polite">
180
+ <div class="smx-auth-icon" aria-hidden="true">{icon}</div>
181
+ <h1 class="smx-auth-headline">{escaped_headline}</h1>
182
+ <div class="smx-auth__body">
183
+ {messages}
184
+ {extra_html}
185
+ </div>
186
+ <div class="smx-auth-footnote">may we all find alignment - softmax, {current_year}</div>
187
+ </main>
188
+ {auto_close_script}
189
+ </body>
190
+ </html>"""
191
+
192
+
193
+ def _success_html() -> str:
194
+ return _render_html(
195
+ title="Authentication Successful",
196
+ headline="You're all set!",
197
+ message_lines=[
198
+ "Authentication complete. You can return to the terminal.",
199
+ "This window will close automatically in a moment.",
200
+ ],
201
+ status="success",
202
+ auto_close_seconds=3,
203
+ extra_html="""
204
+ <div class="smx-auth__actions">
205
+ <button class="smx-auth-button" type="button" onclick="window.close()">Close this window</button>
206
+ </div>
207
+ """,
208
+ )
209
+
210
+
211
+ def _error_html(*, error_message: str) -> str:
212
+ return _render_html(
213
+ title="Authentication Error",
214
+ headline="Something went wrong",
215
+ message_lines=[
216
+ error_message,
217
+ "Please retry the login process or contact support if the issue persists.",
218
+ ],
219
+ status="error",
220
+ )
221
+
222
+
223
+ def _find_free_port() -> int:
224
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
225
+ sock.bind(("127.0.0.1", 0))
226
+ return sock.getsockname()[1]
227
+
228
+
229
+ def _open_browser(*, url: str) -> None:
230
+ webbrowser.open(url)
231
+
232
+
233
+ def _finish_authentication(session: _CLIAuthSession, *, token: str | None = None, error: str | None = None) -> bool:
234
+ with session.completion_lock:
235
+ if session.auth_completed.is_set():
236
+ return False
237
+ if token is not None:
238
+ session.token = token
239
+ if error is not None:
240
+ session.error = error
241
+ session.auth_completed.set()
242
+ return True
243
+
244
+
245
+ def _create_app(session: _CLIAuthSession) -> FastAPI:
246
+ app = FastAPI(title="CLI OAuth2 Callback Server")
247
+ app.add_middleware(
248
+ CORSMiddleware,
249
+ allow_origins=["*"],
250
+ allow_credentials=False,
251
+ allow_methods=["GET"],
252
+ allow_headers=["*"],
253
+ )
254
+
255
+ @app.get("/callback")
256
+ async def callback(request: Request) -> HTMLResponse:
257
+ try:
258
+ token = request.query_params.get("token")
259
+ if not token:
260
+ _finish_authentication(session, error="No token received in callback")
261
+ return HTMLResponse(content=_error_html(error_message="No token received"), status_code=400)
262
+
263
+ _finish_authentication(session, token=token)
264
+ return HTMLResponse(content=_success_html())
265
+ except Exception as exc:
266
+ _finish_authentication(session, error=f"Callback error: {exc}")
267
+ return HTMLResponse(content=_error_html(error_message=f"Error: {exc}"), status_code=500)
268
+
269
+ return app
270
+
271
+
272
+ def _validate_token(*, login_server: str, token: str) -> bool | None:
273
+ validate_url = f"{login_server.rstrip('/')}/validate"
274
+ try:
275
+ response = httpx.get(
276
+ validate_url,
277
+ headers={"Authorization": f"Bearer {token}"},
278
+ timeout=5.0,
279
+ )
280
+ except httpx.HTTPError:
281
+ return None
282
+
283
+ if response.status_code == 200:
284
+ data = response.json()
285
+ return bool(data.get("valid"))
286
+ if response.status_code in {400, 401, 403, 404}:
287
+ return False
288
+ return None
289
+
290
+
291
+ def _print_login_instructions(*, auth_url: str, agent_hint: str | None) -> None:
292
+ if agent_hint:
293
+ console.print(
294
+ Panel(
295
+ agent_hint,
296
+ title="🤖 Agent Hint",
297
+ border_style="cyan",
298
+ )
299
+ )
300
+ console.print()
301
+ console.print("Open this URL in any browser to sign in:")
302
+ console.print()
303
+ console.print(auth_url)
304
+ console.print()
305
+
306
+
307
+ def _start_manual_token_prompt(*, session: _CLIAuthSession, login_server: str) -> None:
308
+ def prompt_loop() -> None:
309
+ while not session.auth_completed.is_set():
310
+ try:
311
+ token = input("Paste token here when ready: ").strip()
312
+ except EOFError:
313
+ return
314
+
315
+ if session.auth_completed.is_set():
316
+ return
317
+ if not token:
318
+ continue
319
+
320
+ validation_result = _validate_token(login_server=login_server, token=token)
321
+ if validation_result is False:
322
+ console.print("Invalid token. Please try again.", style="red")
323
+ continue
324
+ if validation_result is None:
325
+ console.print("Could not validate token right now. Saving it anyway.", style="yellow")
326
+
327
+ _finish_authentication(session, token=token)
328
+ return
329
+
330
+ threading.Thread(target=prompt_loop, daemon=True).start()
331
+
332
+
333
+ def _run_server(*, session: _CLIAuthSession, port: int) -> None:
334
+ try:
335
+ app = _create_app(session)
336
+ config = uvicorn.Config(
337
+ app=app,
338
+ host="127.0.0.1",
339
+ port=port,
340
+ log_level="error",
341
+ access_log=False,
342
+ )
343
+ asyncio.run(uvicorn.Server(config).serve())
344
+ except Exception as exc:
345
+ session.error = f"Server error: {exc}"
346
+
347
+
348
+ def _wait_for_callback_server_to_start(*, session: _CLIAuthSession, port: int, timeout_seconds: float = 3.0) -> bool:
349
+ deadline = time.time() + timeout_seconds
350
+ while time.time() < deadline:
351
+ if session.error:
352
+ return False
353
+ try:
354
+ with socket.create_connection(("127.0.0.1", port), timeout=0.2):
355
+ return True
356
+ except OSError:
357
+ time.sleep(0.05)
358
+ return False
359
+
360
+
361
+ def do_interactive_login_for_token(
362
+ *,
363
+ login_server: str,
364
+ server_to_save_token_under: str,
365
+ token_kind: TokenKind,
366
+ agent_hint: str | None,
367
+ open_browser: bool,
368
+ ) -> None:
369
+ """Run the CLI browser login flow and save the resulting token."""
370
+ assert sys.stdin.isatty(), "This function should only be called when stdin is a TTY"
371
+
372
+ session = _CLIAuthSession()
373
+ callback_url: str | None = None
374
+ port = _find_free_port()
375
+
376
+ threading.Thread(target=_run_server, kwargs={"session": session, "port": port}, daemon=True).start()
377
+ if _wait_for_callback_server_to_start(session=session, port=port):
378
+ callback_url = f"http://127.0.0.1:{port}/callback"
379
+ session.error = None
380
+
381
+ auth_url = build_browser_login_url(login_server, callback_url=callback_url)
382
+ _print_login_instructions(auth_url=auth_url, agent_hint=agent_hint)
383
+ if open_browser:
384
+ _open_browser(url=auth_url)
385
+
386
+ _start_manual_token_prompt(session=session, login_server=login_server)
387
+ session.auth_completed.wait()
388
+ if session.error:
389
+ raise RuntimeError(session.error)
390
+ if not session.token:
391
+ raise RuntimeError("No token received")
392
+
393
+ save_token(token_kind=token_kind, server=server_to_save_token_under, token=session.token)
394
+ print(f"\nToken saved for {server_to_save_token_under}")
395
+ print()
@@ -0,0 +1,96 @@
1
+ import os
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+
8
+ class TokenKind(StrEnum):
9
+ COGAMES = "cogames"
10
+ OBSERVATORY = "observatory"
11
+
12
+
13
+ def _token_file_name(*, token_kind: TokenKind) -> str:
14
+ if token_kind == TokenKind.COGAMES:
15
+ return "cogames.yaml"
16
+ if token_kind == TokenKind.OBSERVATORY:
17
+ return "config.yaml"
18
+ raise AssertionError(f"Unhandled token kind: {token_kind}")
19
+
20
+
21
+ def _token_storage_key(*, token_kind: TokenKind) -> str | None:
22
+ if token_kind == TokenKind.COGAMES:
23
+ return "login_tokens"
24
+ if token_kind == TokenKind.OBSERVATORY:
25
+ return "observatory_tokens"
26
+ raise AssertionError(f"Unhandled token kind: {token_kind}")
27
+
28
+
29
+ def _token_file_path(*, token_file_name: str) -> Path:
30
+ config_dir = Path.home() / ".metta"
31
+ config_dir.mkdir(parents=True, exist_ok=True)
32
+ return config_dir / token_file_name
33
+
34
+
35
+ def _load_token_data(*, token_file_name: str) -> dict:
36
+ token_file = _token_file_path(token_file_name=token_file_name)
37
+ if not token_file.exists():
38
+ return {}
39
+
40
+ with open(token_file, "r") as f:
41
+ return yaml.safe_load(f) or {}
42
+
43
+
44
+ def load_token(*, token_kind: TokenKind, server: str) -> str | None:
45
+ data = _load_token_data(token_file_name=_token_file_name(token_kind=token_kind))
46
+ assert isinstance(data, dict), "Token storage file must contain a mapping at the top level"
47
+
48
+ token_storage_key = _token_storage_key(token_kind=token_kind)
49
+ tokens = data.get(token_storage_key, {}) if token_storage_key else data
50
+ assert isinstance(tokens, dict), "Token storage section must be a mapping"
51
+
52
+ token = tokens.get(server)
53
+ return token if isinstance(token, str) else None
54
+
55
+
56
+ def save_token(*, token_kind: TokenKind, server: str, token: str) -> None:
57
+ token_file_name = _token_file_name(token_kind=token_kind)
58
+ token_storage_key = _token_storage_key(token_kind=token_kind)
59
+ data = _load_token_data(token_file_name=token_file_name)
60
+ assert isinstance(data, dict), "Token storage file must contain a mapping at the top level"
61
+
62
+ if token_storage_key:
63
+ tokens = data.setdefault(token_storage_key, {})
64
+ assert isinstance(tokens, dict), "Token storage section must be a mapping"
65
+ tokens[server] = token
66
+ else:
67
+ data[server] = token
68
+
69
+ token_file = _token_file_path(token_file_name=token_file_name)
70
+ with open(token_file, "w") as f:
71
+ yaml.safe_dump(data, f, default_flow_style=False)
72
+ os.chmod(token_file, 0o600)
73
+
74
+
75
+ def delete_token(*, token_kind: TokenKind, server: str) -> bool:
76
+ token_file_name = _token_file_name(token_kind=token_kind)
77
+ token_storage_key = _token_storage_key(token_kind=token_kind)
78
+ data = _load_token_data(token_file_name=token_file_name)
79
+ assert isinstance(data, dict)
80
+
81
+ if token_storage_key:
82
+ tokens = data.get(token_storage_key, {})
83
+ assert isinstance(tokens, dict)
84
+ if server not in tokens:
85
+ return False
86
+ del tokens[server]
87
+ else:
88
+ if server not in data:
89
+ return False
90
+ del data[server]
91
+
92
+ token_file = _token_file_path(token_file_name=token_file_name)
93
+ with open(token_file, "w") as f:
94
+ yaml.safe_dump(data, f, default_flow_style=False)
95
+ os.chmod(token_file, 0o600)
96
+ return True
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: softmax-cli
3
+ Version: 0.1.0
4
+ Summary: Softmax CLI — authentication and account tools
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Programming Language :: Python :: 3.12
7
+ Requires-Python: <3.13,>=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer>=0.19.2
10
+ Requires-Dist: rich>=13.7.0
11
+ Requires-Dist: fastapi>=0.115.0
12
+ Requires-Dist: uvicorn>=0.34.0
13
+ Requires-Dist: httpx>=0.28.1
14
+ Requires-Dist: pyyaml>=6.0.2
@@ -0,0 +1,14 @@
1
+ pyproject.toml
2
+ src/softmax/__init__.py
3
+ src/softmax/_console.py
4
+ src/softmax/auth.py
5
+ src/softmax/cli.py
6
+ src/softmax/perform_login.py
7
+ src/softmax/token_storage.py
8
+ src/softmax_cli.egg-info/PKG-INFO
9
+ src/softmax_cli.egg-info/SOURCES.txt
10
+ src/softmax_cli.egg-info/dependency_links.txt
11
+ src/softmax_cli.egg-info/entry_points.txt
12
+ src/softmax_cli.egg-info/requires.txt
13
+ src/softmax_cli.egg-info/top_level.txt
14
+ tests/test_auth_login.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ softmax = softmax.cli:app
@@ -0,0 +1,6 @@
1
+ typer>=0.19.2
2
+ rich>=13.7.0
3
+ fastapi>=0.115.0
4
+ uvicorn>=0.34.0
5
+ httpx>=0.28.1
6
+ pyyaml>=6.0.2
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ import softmax.perform_login as auth_module
9
+ from softmax.auth import build_browser_login_url, load_token
10
+ from softmax.cli import _build_manual_set_token_command
11
+ from softmax.perform_login import do_interactive_login_for_token
12
+ from softmax.token_storage import TokenKind, save_token
13
+
14
+ COGAMES_AGENT_HINT = (
15
+ "🤖 If you are a coding agent, ask your human to open the URL above and give you the resulting auth token. "
16
+ "You can paste the token into this window or run: softmax set-token '<TOKEN>'"
17
+ )
18
+
19
+
20
+ def test_authenticate_accepts_pasted_token(
21
+ monkeypatch: pytest.MonkeyPatch,
22
+ tmp_path,
23
+ ) -> None:
24
+ monkeypatch.setenv("HOME", str(tmp_path))
25
+
26
+ monkeypatch.setattr("softmax.perform_login._find_free_port", lambda: 43123)
27
+ monkeypatch.setattr("softmax.perform_login._run_server", lambda *, session, port: None)
28
+ monkeypatch.setattr("softmax.perform_login._wait_for_callback_server_to_start", lambda *, session, port: False)
29
+ monkeypatch.setattr("softmax.perform_login._validate_token", lambda *, login_server, token: True)
30
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
31
+ monkeypatch.setattr(builtins, "input", lambda _prompt="": "manual-token-123")
32
+
33
+ do_interactive_login_for_token(
34
+ login_server="https://softmax.com/api",
35
+ server_to_save_token_under="https://softmax.com/api",
36
+ token_kind=TokenKind.COGAMES,
37
+ agent_hint=COGAMES_AGENT_HINT,
38
+ open_browser=False,
39
+ )
40
+ assert load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api") == "manual-token-123"
41
+
42
+
43
+ def test_authenticate_skips_browser_when_requested(
44
+ monkeypatch: pytest.MonkeyPatch,
45
+ tmp_path,
46
+ capsys: pytest.CaptureFixture[str],
47
+ ) -> None:
48
+ monkeypatch.setenv("HOME", str(tmp_path))
49
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
50
+ opened = {"called": False}
51
+
52
+ monkeypatch.setattr("softmax.perform_login._find_free_port", lambda: 43124)
53
+ monkeypatch.setattr("softmax.perform_login._run_server", lambda *, session, port: None)
54
+ monkeypatch.setattr(
55
+ "softmax.perform_login._wait_for_callback_server_to_start",
56
+ lambda *, session, port: False,
57
+ )
58
+ monkeypatch.setattr(
59
+ "softmax.perform_login._open_browser",
60
+ lambda *, url: opened.__setitem__("called", True) or True,
61
+ )
62
+ monkeypatch.setattr(
63
+ "softmax.perform_login._start_manual_token_prompt",
64
+ lambda *, session, login_server: auth_module._finish_authentication(session, token="manual-token-456"),
65
+ )
66
+
67
+ do_interactive_login_for_token(
68
+ login_server="https://softmax.com/api",
69
+ server_to_save_token_under="https://softmax.com/api",
70
+ token_kind=TokenKind.COGAMES,
71
+ agent_hint=COGAMES_AGENT_HINT,
72
+ open_browser=False,
73
+ )
74
+ assert opened["called"] is False
75
+ output = capsys.readouterr().out
76
+ assert "Open this URL in any browser to sign in:" in output
77
+ assert "softmax set-token '<TOKEN>'" in output
78
+
79
+
80
+ def test_authenticate_falls_back_to_manual_when_callback_server_fails(
81
+ monkeypatch: pytest.MonkeyPatch,
82
+ tmp_path,
83
+ capsys: pytest.CaptureFixture[str],
84
+ ) -> None:
85
+ monkeypatch.setenv("HOME", str(tmp_path))
86
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
87
+ captured_urls: list[str] = []
88
+
89
+ monkeypatch.setattr("softmax.perform_login._find_free_port", lambda: 43125)
90
+ monkeypatch.setattr(
91
+ "softmax.perform_login._wait_for_callback_server_to_start",
92
+ lambda *, session, port: False,
93
+ )
94
+ monkeypatch.setattr("softmax.perform_login._run_server", lambda *, session, port: None)
95
+ monkeypatch.setattr(
96
+ "softmax.perform_login._open_browser",
97
+ lambda *, url: captured_urls.append(url) or True,
98
+ )
99
+ monkeypatch.setattr(
100
+ "softmax.perform_login._start_manual_token_prompt",
101
+ lambda *, session, login_server: auth_module._finish_authentication(session, token="manual-token-789"),
102
+ )
103
+
104
+ do_interactive_login_for_token(
105
+ login_server="https://softmax.com/api",
106
+ server_to_save_token_under="https://softmax.com/api",
107
+ token_kind=TokenKind.COGAMES,
108
+ agent_hint=COGAMES_AGENT_HINT,
109
+ open_browser=True,
110
+ )
111
+ output = capsys.readouterr().out
112
+ assert "Open this URL in any browser to sign in:" in output
113
+ assert captured_urls == ["https://softmax.com/cli-login"]
114
+
115
+
116
+ def test_authenticate_reprompts_after_invalid_token(
117
+ monkeypatch: pytest.MonkeyPatch,
118
+ tmp_path,
119
+ capsys: pytest.CaptureFixture[str],
120
+ ) -> None:
121
+ monkeypatch.setenv("HOME", str(tmp_path))
122
+
123
+ monkeypatch.setattr("softmax.perform_login._find_free_port", lambda: 43126)
124
+ monkeypatch.setattr("softmax.perform_login._wait_for_callback_server_to_start", lambda *, session, port: False)
125
+ monkeypatch.setattr("softmax.perform_login._run_server", lambda *, session, port: None)
126
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
127
+
128
+ entered_tokens = iter(["bad-token", "good-token"])
129
+ monkeypatch.setattr(builtins, "input", lambda _prompt="": next(entered_tokens))
130
+ validation_results = iter([False, True])
131
+ monkeypatch.setattr(
132
+ "softmax.perform_login._validate_token",
133
+ lambda *, login_server, token: next(validation_results),
134
+ )
135
+
136
+ do_interactive_login_for_token(
137
+ login_server="https://softmax.com/api",
138
+ server_to_save_token_under="https://softmax.com/api",
139
+ token_kind=TokenKind.COGAMES,
140
+ agent_hint=COGAMES_AGENT_HINT,
141
+ open_browser=False,
142
+ )
143
+ assert load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api") == "good-token"
144
+ assert "Invalid token. Please try again." in capsys.readouterr().out
145
+
146
+
147
+ def test_manual_command_includes_nondefault_login_server() -> None:
148
+ assert _build_manual_set_token_command(login_server="https://softmax.com/api") == "softmax set-token '<TOKEN>'"
149
+ assert (
150
+ _build_manual_set_token_command(login_server="https://example.ngrok.app/api")
151
+ == "softmax set-token '<TOKEN>' --login-server 'https://example.ngrok.app/api'"
152
+ )
153
+
154
+
155
+ def test_generic_authenticator_does_not_print_cogames_agent_hint(
156
+ monkeypatch: pytest.MonkeyPatch,
157
+ tmp_path,
158
+ capsys: pytest.CaptureFixture[str],
159
+ ) -> None:
160
+ monkeypatch.setenv("HOME", str(tmp_path))
161
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
162
+
163
+ monkeypatch.setattr("softmax.perform_login._find_free_port", lambda: 43127)
164
+ monkeypatch.setattr(
165
+ "softmax.perform_login._wait_for_callback_server_to_start",
166
+ lambda *, session, port: False,
167
+ )
168
+ monkeypatch.setattr("softmax.perform_login._run_server", lambda *, session, port: None)
169
+ monkeypatch.setattr(
170
+ "softmax.perform_login._start_manual_token_prompt",
171
+ lambda *, session, login_server: auth_module._finish_authentication(session, token="manual-token-000"),
172
+ )
173
+
174
+ do_interactive_login_for_token(
175
+ login_server="https://softmax.com/api",
176
+ server_to_save_token_under="token-key",
177
+ token_kind=TokenKind.OBSERVATORY,
178
+ agent_hint=None,
179
+ open_browser=False,
180
+ )
181
+ output = capsys.readouterr().out
182
+ assert "Open this URL in any browser to sign in:" in output
183
+ assert "softmax set-token" not in output
184
+ assert "🤖 If you are a coding agent" not in output
185
+
186
+
187
+ def test_generic_authenticator_works_without_agent_hint(
188
+ monkeypatch: pytest.MonkeyPatch,
189
+ tmp_path,
190
+ ) -> None:
191
+ monkeypatch.setenv("HOME", str(tmp_path))
192
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
193
+ monkeypatch.setattr("softmax.perform_login._find_free_port", lambda: 43128)
194
+ monkeypatch.setattr("softmax.perform_login._wait_for_callback_server_to_start", lambda *, session, port: False)
195
+ monkeypatch.setattr("softmax.perform_login._run_server", lambda *, session, port: None)
196
+ monkeypatch.setattr(
197
+ "softmax.perform_login._start_manual_token_prompt",
198
+ lambda *, session, login_server: auth_module._finish_authentication(session, token="manual-token-001"),
199
+ )
200
+
201
+ do_interactive_login_for_token(
202
+ login_server="https://softmax.com/api",
203
+ server_to_save_token_under="token-key",
204
+ token_kind=TokenKind.OBSERVATORY,
205
+ agent_hint=None,
206
+ open_browser=False,
207
+ )
208
+
209
+
210
+ def test_build_browser_login_url_uses_cli_login_path() -> None:
211
+ assert build_browser_login_url("https://softmax.com/api") == "https://softmax.com/cli-login"
212
+ assert (
213
+ build_browser_login_url(
214
+ "https://softmax.com/api",
215
+ callback_url="http://127.0.0.1:5555/callback",
216
+ )
217
+ == "https://softmax.com/cli-login?callback=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback"
218
+ )
219
+
220
+
221
+ def test_interactive_login_requires_tty(
222
+ monkeypatch: pytest.MonkeyPatch,
223
+ ) -> None:
224
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
225
+
226
+ with pytest.raises(AssertionError, match="only be called when stdin is a TTY"):
227
+ do_interactive_login_for_token(
228
+ login_server="https://softmax.com/api",
229
+ server_to_save_token_under="https://softmax.com/api",
230
+ token_kind=TokenKind.COGAMES,
231
+ agent_hint=COGAMES_AGENT_HINT,
232
+ open_browser=False,
233
+ )
234
+
235
+
236
+ def test_load_token_raises_for_malformed_top_level_yaml(
237
+ monkeypatch: pytest.MonkeyPatch,
238
+ tmp_path,
239
+ ) -> None:
240
+ monkeypatch.setenv("HOME", str(tmp_path))
241
+ config_dir = tmp_path / ".metta"
242
+ config_dir.mkdir()
243
+ (config_dir / "cogames.yaml").write_text("- not-a-mapping\n")
244
+
245
+ with pytest.raises(AssertionError, match="top level"):
246
+ load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api")
247
+
248
+
249
+ def test_save_token_raises_for_malformed_storage_section(
250
+ monkeypatch: pytest.MonkeyPatch,
251
+ tmp_path,
252
+ ) -> None:
253
+ monkeypatch.setenv("HOME", str(tmp_path))
254
+ config_dir = tmp_path / ".metta"
255
+ config_dir.mkdir()
256
+ (config_dir / "cogames.yaml").write_text("login_tokens: nope\n")
257
+
258
+ with pytest.raises(AssertionError, match="section must be a mapping"):
259
+ save_token(
260
+ token_kind=TokenKind.COGAMES,
261
+ server="https://softmax.com/api",
262
+ token="abc",
263
+ )