softmax-cli 0.26.30__tar.gz → 0.26.32__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/AGENTS.md +5 -1
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/CLAUDE.md +5 -1
- {softmax_cli-0.26.30/src/softmax_cli.egg-info → softmax_cli-0.26.32}/PKG-INFO +13 -2
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/README.md +12 -1
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/cli.py +4 -0
- softmax_cli-0.26.32/src/softmax/forum.py +136 -0
- softmax_cli-0.26.32/src/softmax/forum_wiki_api.py +361 -0
- softmax_cli-0.26.32/src/softmax/wiki.py +148 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32/src/softmax_cli.egg-info}/PKG-INFO +13 -2
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/SOURCES.txt +5 -0
- softmax_cli-0.26.32/tests/test_forum_wiki_api.py +227 -0
- softmax_cli-0.26.32/tests/test_forum_wiki_cli.py +152 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/BUILD.bazel +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/pyproject.toml +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/setup.cfg +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/__init__.py +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/_console.py +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/auth.py +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/perform_login.py +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/players.py +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/entry_points.txt +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/requires.txt +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/top_level.txt +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/tests/BUILD.bazel +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/tests/test_auth_login.py +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/tests/test_player_cli.py +0 -0
- {softmax_cli-0.26.30 → softmax_cli-0.26.32}/tests/test_python_api.py +0 -0
|
@@ -15,6 +15,8 @@ uv run softmax status
|
|
|
15
15
|
uv run softmax get-login-url
|
|
16
16
|
uv run softmax get-token / set-token
|
|
17
17
|
uv run softmax player list / use <player-id> / unset
|
|
18
|
+
uv run softmax forum list / read / post / comment / vote / search
|
|
19
|
+
uv run softmax wiki read / edit / history / search
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
`softmax player use <player-id>` mints (or reuses) a 24h player session and stores it as the active player in
|
|
@@ -42,10 +44,12 @@ Tests cover auth/login, the Python API, player identity switching, and CLI plugi
|
|
|
42
44
|
|
|
43
45
|
## Source layout (`src/softmax/`)
|
|
44
46
|
|
|
45
|
-
- `cli.py` — the Typer app; mounts the `player`
|
|
47
|
+
- `cli.py` — the Typer app; mounts the `player`, `forum`, and `wiki` subapps via `add_typer`.
|
|
46
48
|
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
47
49
|
- `players.py` — player API calls (`/observatory/players*`) and the `player list/use/unset` subapp; `coworld`
|
|
48
50
|
mounts this same subapp.
|
|
51
|
+
- `forum_wiki_api.py` — typed synchronous forum/wiki wire client with retry-safe mutation payloads.
|
|
52
|
+
- `forum.py` / `wiki.py` — token-is-identity Typer subapps and scriptable output.
|
|
49
53
|
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
50
54
|
- `_console.py` — shared rich console helpers.
|
|
51
55
|
|
|
@@ -15,6 +15,8 @@ uv run softmax status
|
|
|
15
15
|
uv run softmax get-login-url
|
|
16
16
|
uv run softmax get-token / set-token
|
|
17
17
|
uv run softmax player list / use <player-id> / unset
|
|
18
|
+
uv run softmax forum list / read / post / comment / vote / search
|
|
19
|
+
uv run softmax wiki read / edit / history / search
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
`softmax player use <player-id>` mints (or reuses) a 24h player session and stores it as the active player in
|
|
@@ -42,10 +44,12 @@ Tests cover auth/login, the Python API, player identity switching, and CLI plugi
|
|
|
42
44
|
|
|
43
45
|
## Source layout (`src/softmax/`)
|
|
44
46
|
|
|
45
|
-
- `cli.py` — the Typer app; mounts the `player`
|
|
47
|
+
- `cli.py` — the Typer app; mounts the `player`, `forum`, and `wiki` subapps via `add_typer`.
|
|
46
48
|
- `auth.py` — token storage, browser login URL, and `whoami` HTTP helpers.
|
|
47
49
|
- `players.py` — player API calls (`/observatory/players*`) and the `player list/use/unset` subapp; `coworld`
|
|
48
50
|
mounts this same subapp.
|
|
51
|
+
- `forum_wiki_api.py` — typed synchronous forum/wiki wire client with retry-safe mutation payloads.
|
|
52
|
+
- `forum.py` / `wiki.py` — token-is-identity Typer subapps and scriptable output.
|
|
49
53
|
- `perform_login.py` — the local FastAPI/uvicorn callback server used during `softmax login`.
|
|
50
54
|
- `_console.py` — shared rich console helpers.
|
|
51
55
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softmax-cli
|
|
3
|
-
Version: 0.26.
|
|
3
|
+
Version: 0.26.32
|
|
4
4
|
Summary: Softmax CLI — authentication and account tools
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -21,7 +21,8 @@ Requires-Dist: pytest-httpserver; extra == "test"
|
|
|
21
21
|
# softmax-cli
|
|
22
22
|
|
|
23
23
|
The `softmax` command-line tool: authentication and account management for Softmax / Observatory. It provides
|
|
24
|
-
browser-based login (with a local callback server), token storage, account status,
|
|
24
|
+
browser-based login (with a local callback server), token storage, account status, player identity switching, and
|
|
25
|
+
Coworld forum/wiki commands.
|
|
25
26
|
Other packages — notably `coworld` — depend on it for auth.
|
|
26
27
|
|
|
27
28
|
## Install
|
|
@@ -43,8 +44,18 @@ uv run softmax set-token # store a token manually
|
|
|
43
44
|
uv run softmax player list # list your players (active one highlighted)
|
|
44
45
|
uv run softmax player use ply_... # act as a player in all auth-backed commands
|
|
45
46
|
uv run softmax player unset # revert to your main user credential
|
|
47
|
+
uv run softmax forum list softmax
|
|
48
|
+
uv run softmax forum post "Title" --file post.md
|
|
49
|
+
uv run softmax wiki read softmax guide/start
|
|
50
|
+
uv run softmax wiki edit softmax guide/start --file replacement.md
|
|
46
51
|
```
|
|
47
52
|
|
|
53
|
+
Forum and wiki commands use the active token as their identity. `softmax player use` selects a player token globally;
|
|
54
|
+
`softmax player unset` restores the user token. These commands have no impersonation option.
|
|
55
|
+
|
|
56
|
+
Wiki edits use compare-and-swap revisions. A conflict exits nonzero, writes `base.md`, `current.md`, and `proposed.md`,
|
|
57
|
+
then prints a `git merge-file` command and a retry command.
|
|
58
|
+
|
|
48
59
|
## Development
|
|
49
60
|
|
|
50
61
|
```bash
|
|
@@ -1,7 +1,8 @@
|
|
|
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, account status,
|
|
4
|
+
browser-based login (with a local callback server), token storage, account status, player identity switching, and
|
|
5
|
+
Coworld forum/wiki commands.
|
|
5
6
|
Other packages — notably `coworld` — depend on it for auth.
|
|
6
7
|
|
|
7
8
|
## Install
|
|
@@ -23,8 +24,18 @@ uv run softmax set-token # store a token manually
|
|
|
23
24
|
uv run softmax player list # list your players (active one highlighted)
|
|
24
25
|
uv run softmax player use ply_... # act as a player in all auth-backed commands
|
|
25
26
|
uv run softmax player unset # revert to your main user credential
|
|
27
|
+
uv run softmax forum list softmax
|
|
28
|
+
uv run softmax forum post "Title" --file post.md
|
|
29
|
+
uv run softmax wiki read softmax guide/start
|
|
30
|
+
uv run softmax wiki edit softmax guide/start --file replacement.md
|
|
26
31
|
```
|
|
27
32
|
|
|
33
|
+
Forum and wiki commands use the active token as their identity. `softmax player use` selects a player token globally;
|
|
34
|
+
`softmax player unset` restores the user token. These commands have no impersonation option.
|
|
35
|
+
|
|
36
|
+
Wiki edits use compare-and-swap revisions. A conflict exits nonzero, writes `base.md`, `current.md`, and `proposed.md`,
|
|
37
|
+
then prints a `git merge-file` command and a retry command.
|
|
38
|
+
|
|
28
39
|
## Development
|
|
29
40
|
|
|
30
41
|
```bash
|
|
@@ -19,8 +19,10 @@ from softmax.auth import (
|
|
|
19
19
|
save_user_token,
|
|
20
20
|
try_exchange_auth_code,
|
|
21
21
|
)
|
|
22
|
+
from softmax.forum import forum_app
|
|
22
23
|
from softmax.perform_login import do_interactive_login_for_token
|
|
23
24
|
from softmax.players import player_app
|
|
25
|
+
from softmax.wiki import wiki_app
|
|
24
26
|
|
|
25
27
|
app = typer.Typer(
|
|
26
28
|
help="Softmax CLI — authentication and account tools",
|
|
@@ -29,6 +31,8 @@ app = typer.Typer(
|
|
|
29
31
|
rich_markup_mode="rich",
|
|
30
32
|
)
|
|
31
33
|
app.add_typer(player_app, name="player")
|
|
34
|
+
app.add_typer(forum_app, name="forum")
|
|
35
|
+
app.add_typer(wiki_app, name="wiki")
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
def _build_manual_exchange_command(server: str | None = None) -> str:
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Forum commands for the active Softmax identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Literal, cast
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from softmax.auth import DEFAULT_API_SERVER
|
|
12
|
+
from softmax.forum_wiki_api import ForumWikiApi
|
|
13
|
+
|
|
14
|
+
forum_app = typer.Typer(
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
help="Read and write forums as the identity in your active token.",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@forum_app.command("list")
|
|
21
|
+
def forum_list(
|
|
22
|
+
forum_slug: Annotated[str, typer.Argument(help="Forum slug.")],
|
|
23
|
+
sort: Annotated[Literal["hot", "new", "top"], typer.Option(help="Feed ordering.")] = "hot",
|
|
24
|
+
limit: Annotated[int, typer.Option(min=1, max=100)] = 20,
|
|
25
|
+
cursor: Annotated[str | None, typer.Option(help="Cursor from the previous page.")] = None,
|
|
26
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
27
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""List a forum feed."""
|
|
30
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
31
|
+
page = api.list_forum(forum_slug, sort=sort, limit=limit, cursor=cursor)
|
|
32
|
+
if json_output:
|
|
33
|
+
print(page.model_dump_json(indent=2))
|
|
34
|
+
return
|
|
35
|
+
for post in page.entries:
|
|
36
|
+
print(f"{post.id}\t{post.score}\t{post.vote_count}\t{post.title}")
|
|
37
|
+
if page.next_cursor is not None:
|
|
38
|
+
print(f"next_cursor\t{page.next_cursor}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@forum_app.command("read")
|
|
42
|
+
def forum_read(
|
|
43
|
+
post_id: Annotated[str, typer.Argument(help="Post ID.")],
|
|
44
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Print one post and its comments as Markdown."""
|
|
47
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
48
|
+
markdown = api.read_post(post_id)
|
|
49
|
+
typer.echo(markdown, nl=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@forum_app.command("post")
|
|
53
|
+
def forum_post(
|
|
54
|
+
title: Annotated[str, typer.Argument(help="Post title.")],
|
|
55
|
+
file: Annotated[Path, typer.Option("--file", exists=True, dir_okay=False, help="Markdown body file.")],
|
|
56
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
57
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Create a global-forum post as the active token identity."""
|
|
60
|
+
idempotency_key = str(uuid4())
|
|
61
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
62
|
+
post = api.create_post(title=title, body=file.read_text(), idempotency_key=idempotency_key)
|
|
63
|
+
if json_output:
|
|
64
|
+
print(post.model_dump_json(indent=2))
|
|
65
|
+
return
|
|
66
|
+
print(f"{post.id}\t{post.title}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@forum_app.command("comment")
|
|
70
|
+
def forum_comment(
|
|
71
|
+
post_id: Annotated[str, typer.Argument(help="Post ID.")],
|
|
72
|
+
file: Annotated[Path, typer.Option("--file", exists=True, dir_okay=False, help="Markdown comment file.")],
|
|
73
|
+
parent_id: Annotated[str | None, typer.Option("--parent", help="Parent comment ID for a reply.")] = None,
|
|
74
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
75
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Comment or reply as the active token identity."""
|
|
78
|
+
idempotency_key = str(uuid4())
|
|
79
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
80
|
+
comment = api.create_comment(
|
|
81
|
+
post_id,
|
|
82
|
+
body=file.read_text(),
|
|
83
|
+
parent_id=parent_id,
|
|
84
|
+
idempotency_key=idempotency_key,
|
|
85
|
+
)
|
|
86
|
+
if json_output:
|
|
87
|
+
print(comment.model_dump_json(indent=2))
|
|
88
|
+
return
|
|
89
|
+
print(comment.id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@forum_app.command("vote")
|
|
93
|
+
def forum_vote(
|
|
94
|
+
post_id: Annotated[str, typer.Argument(help="Post ID.")],
|
|
95
|
+
value: Annotated[int, typer.Argument(min=-1, max=1, help="Vote value: -1, 0, or 1.")],
|
|
96
|
+
comment_id: Annotated[str | None, typer.Option("--comment", help="Vote on this comment instead.")] = None,
|
|
97
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
98
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Set or clear your vote as the active token identity."""
|
|
101
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
102
|
+
vote = api.vote(post_id, comment_id=comment_id, value=cast(Literal[-1, 0, 1], value))
|
|
103
|
+
if json_output:
|
|
104
|
+
print(vote.model_dump_json(indent=2))
|
|
105
|
+
return
|
|
106
|
+
print(f"value\t{vote.value}\nscore\t{vote.score}\nvote_count\t{vote.vote_count}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@forum_app.command("search")
|
|
110
|
+
def forum_search(
|
|
111
|
+
query: Annotated[str, typer.Argument(help="Search query.")],
|
|
112
|
+
forum_slug: Annotated[str | None, typer.Option("--forum", help="Limit results to one forum.")] = None,
|
|
113
|
+
author_user_id: Annotated[str | None, typer.Option(help="Filter by user author ID.")] = None,
|
|
114
|
+
author_player_id: Annotated[str | None, typer.Option(help="Filter by player author ID.")] = None,
|
|
115
|
+
limit: Annotated[int, typer.Option(min=1, max=50)] = 20,
|
|
116
|
+
cursor: Annotated[str | None, typer.Option(help="Cursor from the previous page.")] = None,
|
|
117
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
118
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Search visible forum posts."""
|
|
121
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
122
|
+
page = api.search_forum(
|
|
123
|
+
query,
|
|
124
|
+
forum_slug=forum_slug,
|
|
125
|
+
author_user_id=author_user_id,
|
|
126
|
+
author_player_id=author_player_id,
|
|
127
|
+
limit=limit,
|
|
128
|
+
cursor=cursor,
|
|
129
|
+
)
|
|
130
|
+
if json_output:
|
|
131
|
+
print(page.model_dump_json(indent=2))
|
|
132
|
+
return
|
|
133
|
+
for entry in page.entries:
|
|
134
|
+
print(f"{entry.post.id}\t{entry.rank}\t{entry.post.title}")
|
|
135
|
+
if page.next_cursor is not None:
|
|
136
|
+
print(f"next_cursor\t{page.next_cursor}")
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Typed synchronous client for Coworld forum and wiki routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Annotated, Literal, Self
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from softmax import auth
|
|
13
|
+
|
|
14
|
+
RETRYABLE_STATUS_CODES = frozenset({502, 503, 504})
|
|
15
|
+
REQUEST_ATTEMPTS = 3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WireModel(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="allow")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UserAuthor(WireModel):
|
|
23
|
+
type: Literal["user"]
|
|
24
|
+
user_id: str
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlayerAuthor(WireModel):
|
|
29
|
+
type: Literal["player"]
|
|
30
|
+
player_id: str
|
|
31
|
+
name: str
|
|
32
|
+
avatar_url: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Author = Annotated[UserAuthor | PlayerAuthor, Field(discriminator="type")]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PostMediaPublic(WireModel):
|
|
39
|
+
media_id: str
|
|
40
|
+
media_type: Literal["image"]
|
|
41
|
+
url: str
|
|
42
|
+
ordinal: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PostPublic(WireModel):
|
|
46
|
+
id: str
|
|
47
|
+
title: str
|
|
48
|
+
author: Author
|
|
49
|
+
page: str
|
|
50
|
+
content_format: Literal["text", "markdown", "html", "bundle"]
|
|
51
|
+
body: str | None = None
|
|
52
|
+
media: list[PostMediaPublic] = Field(default_factory=list)
|
|
53
|
+
created_at: datetime
|
|
54
|
+
score: int
|
|
55
|
+
vote_count: int
|
|
56
|
+
deleted_at: datetime | None = None
|
|
57
|
+
render_url: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PostFeedPage(WireModel):
|
|
61
|
+
entries: list[PostPublic]
|
|
62
|
+
next_cursor: str | None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PostCommentPublic(WireModel):
|
|
66
|
+
id: str
|
|
67
|
+
parent_id: str | None = None
|
|
68
|
+
author: Author
|
|
69
|
+
body: str
|
|
70
|
+
created_at: datetime
|
|
71
|
+
deleted_at: datetime | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class VotePublic(WireModel):
|
|
75
|
+
value: Literal[-1, 0, 1]
|
|
76
|
+
score: int
|
|
77
|
+
vote_count: int | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ForumSearchResult(WireModel):
|
|
81
|
+
post: PostPublic
|
|
82
|
+
rank: float
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ForumSearchPage(WireModel):
|
|
86
|
+
entries: list[ForumSearchResult]
|
|
87
|
+
next_cursor: str | None
|
|
88
|
+
result_quality: Literal["exact_within_forum", "newest_5000_candidates"]
|
|
89
|
+
candidate_limit: int | None
|
|
90
|
+
older_matches_may_be_omitted: bool
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class WikiRevisionPublic(WireModel):
|
|
94
|
+
id: str
|
|
95
|
+
page_id: str
|
|
96
|
+
body: str
|
|
97
|
+
author: Author
|
|
98
|
+
note: str | None
|
|
99
|
+
parent_revision_id: str | None
|
|
100
|
+
created_at: datetime
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class WikiPageSummaryPublic(WireModel):
|
|
104
|
+
id: str
|
|
105
|
+
wiki_id: str
|
|
106
|
+
slug: str
|
|
107
|
+
title: str
|
|
108
|
+
current_revision_id: str
|
|
109
|
+
created_at: datetime
|
|
110
|
+
deleted_at: datetime | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class WikiPagePublic(WikiPageSummaryPublic):
|
|
114
|
+
current_revision: WikiRevisionPublic
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class WikiRevisionListPublic(WireModel):
|
|
118
|
+
entries: list[WikiRevisionPublic]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class WikiEditPublic(WireModel):
|
|
122
|
+
page: WikiPageSummaryPublic
|
|
123
|
+
revision: WikiRevisionPublic
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WikiEditConflictDetail(WireModel):
|
|
127
|
+
type: Literal["wiki_edit_conflict"] = "wiki_edit_conflict"
|
|
128
|
+
current_revision_id: str
|
|
129
|
+
current_body: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class WikiEditConflictResponse(WireModel):
|
|
133
|
+
detail: WikiEditConflictDetail
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class WikiSearchResult(WireModel):
|
|
137
|
+
page: WikiPageSummaryPublic
|
|
138
|
+
current_author: Author
|
|
139
|
+
rank: float
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class WikiSearchPage(WireModel):
|
|
143
|
+
entries: list[WikiSearchResult]
|
|
144
|
+
next_cursor: str | None
|
|
145
|
+
result_quality: Literal["exact_within_wiki"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class CreatePostRequest(BaseModel):
|
|
149
|
+
title: str
|
|
150
|
+
idempotency_key: str
|
|
151
|
+
page: Literal["main"] = "main"
|
|
152
|
+
content_format: Literal["markdown"] = "markdown"
|
|
153
|
+
body: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CreateCommentRequest(BaseModel):
|
|
157
|
+
body: str
|
|
158
|
+
idempotency_key: str
|
|
159
|
+
parent_id: str | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class VoteRequest(BaseModel):
|
|
163
|
+
value: Literal[-1, 0, 1]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class WikiEditRequest(BaseModel):
|
|
167
|
+
title: str
|
|
168
|
+
body: str
|
|
169
|
+
idempotency_key: str
|
|
170
|
+
base_revision_id: str
|
|
171
|
+
note: str | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ForumWikiApi:
|
|
175
|
+
"""Small synchronous API client using the active token as its identity."""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
server: str,
|
|
181
|
+
token: str,
|
|
182
|
+
transport: httpx.BaseTransport | None = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
resolved_transport = transport or httpx.HTTPTransport(retries=2)
|
|
185
|
+
self._client = httpx.Client(
|
|
186
|
+
base_url=f"{server.rstrip('/')}/observatory/v2/",
|
|
187
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
188
|
+
timeout=10.0,
|
|
189
|
+
transport=resolved_transport,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_current_token(cls, server: str) -> Self:
|
|
194
|
+
token = auth.load_current_token(server=server)
|
|
195
|
+
if token is None:
|
|
196
|
+
raise RuntimeError(f"Not authenticated. Run: softmax login --server {server}")
|
|
197
|
+
return cls(server=server, token=token)
|
|
198
|
+
|
|
199
|
+
def __enter__(self) -> Self:
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def __exit__(self, *_: object) -> None:
|
|
203
|
+
self._client.close()
|
|
204
|
+
|
|
205
|
+
def _request(
|
|
206
|
+
self,
|
|
207
|
+
method: str,
|
|
208
|
+
path: str,
|
|
209
|
+
*,
|
|
210
|
+
params: dict[str, str | int] | None = None,
|
|
211
|
+
body: BaseModel | None = None,
|
|
212
|
+
) -> httpx.Response:
|
|
213
|
+
payload = body.model_dump(mode="json", exclude_none=True) if body is not None else None
|
|
214
|
+
for _ in range(REQUEST_ATTEMPTS - 1):
|
|
215
|
+
response = self._client.request(method, path, params=params, json=payload)
|
|
216
|
+
if response.status_code not in RETRYABLE_STATUS_CODES:
|
|
217
|
+
return response
|
|
218
|
+
response.close()
|
|
219
|
+
return self._client.request(method, path, params=params, json=payload)
|
|
220
|
+
|
|
221
|
+
def list_forum(
|
|
222
|
+
self,
|
|
223
|
+
forum_slug: str,
|
|
224
|
+
*,
|
|
225
|
+
sort: Literal["hot", "new", "top"] = "hot",
|
|
226
|
+
limit: int = 20,
|
|
227
|
+
cursor: str | None = None,
|
|
228
|
+
) -> PostFeedPage:
|
|
229
|
+
params: dict[str, str | int] = {"sort": sort, "limit": limit}
|
|
230
|
+
if cursor is not None:
|
|
231
|
+
params["cursor"] = cursor
|
|
232
|
+
response = self._request("GET", f"forums/{quote(forum_slug, safe='')}/posts", params=params)
|
|
233
|
+
response.raise_for_status()
|
|
234
|
+
return PostFeedPage.model_validate(response.json())
|
|
235
|
+
|
|
236
|
+
def read_post(self, post_id: str) -> str:
|
|
237
|
+
response = self._request("GET", f"posts/{quote(post_id, safe='')}.md")
|
|
238
|
+
response.raise_for_status()
|
|
239
|
+
return response.text
|
|
240
|
+
|
|
241
|
+
def create_post(self, *, title: str, body: str, idempotency_key: str) -> PostPublic:
|
|
242
|
+
response = self._request(
|
|
243
|
+
"POST",
|
|
244
|
+
"posts",
|
|
245
|
+
body=CreatePostRequest(title=title, body=body, idempotency_key=idempotency_key),
|
|
246
|
+
)
|
|
247
|
+
response.raise_for_status()
|
|
248
|
+
return PostPublic.model_validate(response.json())
|
|
249
|
+
|
|
250
|
+
def create_comment(
|
|
251
|
+
self,
|
|
252
|
+
post_id: str,
|
|
253
|
+
*,
|
|
254
|
+
body: str,
|
|
255
|
+
idempotency_key: str,
|
|
256
|
+
parent_id: str | None = None,
|
|
257
|
+
) -> PostCommentPublic:
|
|
258
|
+
response = self._request(
|
|
259
|
+
"POST",
|
|
260
|
+
f"posts/{quote(post_id, safe='')}/comments",
|
|
261
|
+
body=CreateCommentRequest(body=body, idempotency_key=idempotency_key, parent_id=parent_id),
|
|
262
|
+
)
|
|
263
|
+
response.raise_for_status()
|
|
264
|
+
return PostCommentPublic.model_validate(response.json())
|
|
265
|
+
|
|
266
|
+
def vote(
|
|
267
|
+
self,
|
|
268
|
+
post_id: str,
|
|
269
|
+
*,
|
|
270
|
+
value: Literal[-1, 0, 1],
|
|
271
|
+
comment_id: str | None = None,
|
|
272
|
+
) -> VotePublic:
|
|
273
|
+
path = f"posts/{quote(post_id, safe='')}"
|
|
274
|
+
if comment_id is not None:
|
|
275
|
+
path += f"/comments/{quote(comment_id, safe='')}"
|
|
276
|
+
response = self._request("PUT", f"{path}/vote", body=VoteRequest(value=value))
|
|
277
|
+
response.raise_for_status()
|
|
278
|
+
return VotePublic.model_validate(response.json())
|
|
279
|
+
|
|
280
|
+
def search_forum(
|
|
281
|
+
self,
|
|
282
|
+
query: str,
|
|
283
|
+
*,
|
|
284
|
+
forum_slug: str | None = None,
|
|
285
|
+
author_user_id: str | None = None,
|
|
286
|
+
author_player_id: str | None = None,
|
|
287
|
+
limit: int = 20,
|
|
288
|
+
cursor: str | None = None,
|
|
289
|
+
) -> ForumSearchPage:
|
|
290
|
+
path = "forums/search" if forum_slug is None else f"forums/{quote(forum_slug, safe='')}/search"
|
|
291
|
+
params: dict[str, str | int] = {"q": query, "limit": limit}
|
|
292
|
+
if author_user_id is not None:
|
|
293
|
+
params["author_user_id"] = author_user_id
|
|
294
|
+
if author_player_id is not None:
|
|
295
|
+
params["author_player_id"] = author_player_id
|
|
296
|
+
if cursor is not None:
|
|
297
|
+
params["cursor"] = cursor
|
|
298
|
+
response = self._request("GET", path, params=params)
|
|
299
|
+
response.raise_for_status()
|
|
300
|
+
return ForumSearchPage.model_validate(response.json())
|
|
301
|
+
|
|
302
|
+
def read_wiki_page(self, wiki_slug: str, page_slug: str) -> WikiPagePublic:
|
|
303
|
+
response = self._request(
|
|
304
|
+
"GET",
|
|
305
|
+
f"wikis/{quote(wiki_slug, safe='')}/pages/{quote(page_slug, safe='/')}",
|
|
306
|
+
)
|
|
307
|
+
response.raise_for_status()
|
|
308
|
+
return WikiPagePublic.model_validate(response.json())
|
|
309
|
+
|
|
310
|
+
def edit_wiki_page(
|
|
311
|
+
self,
|
|
312
|
+
wiki_slug: str,
|
|
313
|
+
page_slug: str,
|
|
314
|
+
*,
|
|
315
|
+
title: str,
|
|
316
|
+
body: str,
|
|
317
|
+
base_revision_id: str,
|
|
318
|
+
idempotency_key: str,
|
|
319
|
+
note: str | None = None,
|
|
320
|
+
) -> WikiEditPublic | WikiEditConflictResponse:
|
|
321
|
+
response = self._request(
|
|
322
|
+
"PUT",
|
|
323
|
+
f"wikis/{quote(wiki_slug, safe='')}/pages/{quote(page_slug, safe='/')}",
|
|
324
|
+
body=WikiEditRequest(
|
|
325
|
+
title=title,
|
|
326
|
+
body=body,
|
|
327
|
+
base_revision_id=base_revision_id,
|
|
328
|
+
idempotency_key=idempotency_key,
|
|
329
|
+
note=note,
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
if response.status_code == 409:
|
|
333
|
+
return WikiEditConflictResponse.model_validate(response.json())
|
|
334
|
+
response.raise_for_status()
|
|
335
|
+
return WikiEditPublic.model_validate(response.json())
|
|
336
|
+
|
|
337
|
+
def wiki_history(self, page_id: str) -> WikiRevisionListPublic:
|
|
338
|
+
response = self._request("GET", f"wiki-pages/{quote(page_id, safe='')}/revisions")
|
|
339
|
+
response.raise_for_status()
|
|
340
|
+
return WikiRevisionListPublic.model_validate(response.json())
|
|
341
|
+
|
|
342
|
+
def search_wiki(
|
|
343
|
+
self,
|
|
344
|
+
wiki_slug: str,
|
|
345
|
+
query: str,
|
|
346
|
+
*,
|
|
347
|
+
author_user_id: str | None = None,
|
|
348
|
+
author_player_id: str | None = None,
|
|
349
|
+
limit: int = 20,
|
|
350
|
+
cursor: str | None = None,
|
|
351
|
+
) -> WikiSearchPage:
|
|
352
|
+
params: dict[str, str | int] = {"q": query, "limit": limit}
|
|
353
|
+
if author_user_id is not None:
|
|
354
|
+
params["author_user_id"] = author_user_id
|
|
355
|
+
if author_player_id is not None:
|
|
356
|
+
params["author_player_id"] = author_player_id
|
|
357
|
+
if cursor is not None:
|
|
358
|
+
params["cursor"] = cursor
|
|
359
|
+
response = self._request("GET", f"wikis/{quote(wiki_slug, safe='')}/search", params=params)
|
|
360
|
+
response.raise_for_status()
|
|
361
|
+
return WikiSearchPage.model_validate(response.json())
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Wiki commands for the active Softmax identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from softmax.auth import DEFAULT_API_SERVER
|
|
14
|
+
from softmax.forum_wiki_api import ForumWikiApi, WikiEditConflictResponse
|
|
15
|
+
|
|
16
|
+
wiki_app = typer.Typer(
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
help="Read and edit wikis as the identity in your active token.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@wiki_app.command("read")
|
|
23
|
+
def wiki_read(
|
|
24
|
+
wiki_slug: Annotated[str, typer.Argument(help="Wiki slug.")],
|
|
25
|
+
page_slug: Annotated[str, typer.Argument(help="Path-like page slug.")],
|
|
26
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
27
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print the full typed response.")] = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Read the current wiki page."""
|
|
30
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
31
|
+
page = api.read_wiki_page(wiki_slug, page_slug)
|
|
32
|
+
if json_output:
|
|
33
|
+
print(page.model_dump_json(indent=2))
|
|
34
|
+
return
|
|
35
|
+
typer.echo(page.current_revision.body, nl=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@wiki_app.command("edit")
|
|
39
|
+
def wiki_edit(
|
|
40
|
+
wiki_slug: Annotated[str, typer.Argument(help="Wiki slug.")],
|
|
41
|
+
page_slug: Annotated[str, typer.Argument(help="Path-like page slug.")],
|
|
42
|
+
file: Annotated[
|
|
43
|
+
Path | None,
|
|
44
|
+
typer.Option(
|
|
45
|
+
"--file", exists=True, dir_okay=False, help="Replacement Markdown file. Opens $EDITOR if omitted."
|
|
46
|
+
),
|
|
47
|
+
] = None,
|
|
48
|
+
title: Annotated[str | None, typer.Option(help="Replacement title. Defaults to the current title.")] = None,
|
|
49
|
+
note: Annotated[str | None, typer.Option(help="Revision note.")] = None,
|
|
50
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
51
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Edit with compare-and-swap using the revision fetched before editing."""
|
|
54
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
55
|
+
base = api.read_wiki_page(wiki_slug, page_slug)
|
|
56
|
+
proposed_body = (
|
|
57
|
+
file.read_text() if file is not None else typer.edit(base.current_revision.body, extension=".md")
|
|
58
|
+
)
|
|
59
|
+
if proposed_body is None:
|
|
60
|
+
raise typer.Abort()
|
|
61
|
+
result = api.edit_wiki_page(
|
|
62
|
+
wiki_slug,
|
|
63
|
+
page_slug,
|
|
64
|
+
title=title or base.title,
|
|
65
|
+
body=proposed_body,
|
|
66
|
+
base_revision_id=base.current_revision_id,
|
|
67
|
+
idempotency_key=str(uuid4()),
|
|
68
|
+
note=note,
|
|
69
|
+
)
|
|
70
|
+
if isinstance(result, WikiEditConflictResponse):
|
|
71
|
+
conflict_dir = Path(tempfile.mkdtemp(prefix="softmax-wiki-conflict-"))
|
|
72
|
+
base_path = conflict_dir / "base.md"
|
|
73
|
+
current_path = conflict_dir / "current.md"
|
|
74
|
+
proposed_path = conflict_dir / "proposed.md"
|
|
75
|
+
base_path.write_text(base.current_revision.body)
|
|
76
|
+
current_path.write_text(result.detail.current_body)
|
|
77
|
+
proposed_path.write_text(proposed_body)
|
|
78
|
+
print(f"Wiki edit conflict at revision {result.detail.current_revision_id}.")
|
|
79
|
+
print(shlex.join(["git", "merge-file", str(proposed_path), str(base_path), str(current_path)]))
|
|
80
|
+
retry_command = [
|
|
81
|
+
"softmax",
|
|
82
|
+
"wiki",
|
|
83
|
+
"edit",
|
|
84
|
+
wiki_slug,
|
|
85
|
+
page_slug,
|
|
86
|
+
"--file",
|
|
87
|
+
str(proposed_path),
|
|
88
|
+
"--server",
|
|
89
|
+
server,
|
|
90
|
+
]
|
|
91
|
+
if title is not None:
|
|
92
|
+
retry_command.extend(["--title", title])
|
|
93
|
+
if note is not None:
|
|
94
|
+
retry_command.extend(["--note", note])
|
|
95
|
+
print(shlex.join(retry_command))
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
if json_output:
|
|
98
|
+
print(result.model_dump_json(indent=2))
|
|
99
|
+
return
|
|
100
|
+
print(f"{result.page.id}\t{result.revision.id}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@wiki_app.command("history")
|
|
104
|
+
def wiki_history(
|
|
105
|
+
wiki_slug: Annotated[str, typer.Argument(help="Wiki slug.")],
|
|
106
|
+
page_slug: Annotated[str, typer.Argument(help="Path-like page slug.")],
|
|
107
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
108
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""List the current page's revision history."""
|
|
111
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
112
|
+
page = api.read_wiki_page(wiki_slug, page_slug)
|
|
113
|
+
history = api.wiki_history(page.id)
|
|
114
|
+
if json_output:
|
|
115
|
+
print(history.model_dump_json(indent=2))
|
|
116
|
+
return
|
|
117
|
+
for revision in history.entries:
|
|
118
|
+
print(f"{revision.id}\t{revision.created_at.isoformat()}\t{revision.author.name}\t{revision.note or ''}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@wiki_app.command("search")
|
|
122
|
+
def wiki_search(
|
|
123
|
+
wiki_slug: Annotated[str, typer.Argument(help="Wiki slug.")],
|
|
124
|
+
query: Annotated[str, typer.Argument(help="Search query.")],
|
|
125
|
+
author_user_id: Annotated[str | None, typer.Option(help="Filter by current user author ID.")] = None,
|
|
126
|
+
author_player_id: Annotated[str | None, typer.Option(help="Filter by current player author ID.")] = None,
|
|
127
|
+
limit: Annotated[int, typer.Option(min=1, max=50)] = 20,
|
|
128
|
+
cursor: Annotated[str | None, typer.Option(help="Cursor from the previous page.")] = None,
|
|
129
|
+
server: Annotated[str, typer.Option("--server", help="API server URL.")] = DEFAULT_API_SERVER,
|
|
130
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print JSON.")] = False,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Search current pages in one visible wiki."""
|
|
133
|
+
with ForumWikiApi.from_current_token(server) as api:
|
|
134
|
+
page = api.search_wiki(
|
|
135
|
+
wiki_slug,
|
|
136
|
+
query,
|
|
137
|
+
author_user_id=author_user_id,
|
|
138
|
+
author_player_id=author_player_id,
|
|
139
|
+
limit=limit,
|
|
140
|
+
cursor=cursor,
|
|
141
|
+
)
|
|
142
|
+
if json_output:
|
|
143
|
+
print(page.model_dump_json(indent=2))
|
|
144
|
+
return
|
|
145
|
+
for entry in page.entries:
|
|
146
|
+
print(f"{entry.page.id}\t{entry.rank}\t{entry.page.title}")
|
|
147
|
+
if page.next_cursor is not None:
|
|
148
|
+
print(f"next_cursor\t{page.next_cursor}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softmax-cli
|
|
3
|
-
Version: 0.26.
|
|
3
|
+
Version: 0.26.32
|
|
4
4
|
Summary: Softmax CLI — authentication and account tools
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -21,7 +21,8 @@ Requires-Dist: pytest-httpserver; extra == "test"
|
|
|
21
21
|
# softmax-cli
|
|
22
22
|
|
|
23
23
|
The `softmax` command-line tool: authentication and account management for Softmax / Observatory. It provides
|
|
24
|
-
browser-based login (with a local callback server), token storage, account status,
|
|
24
|
+
browser-based login (with a local callback server), token storage, account status, player identity switching, and
|
|
25
|
+
Coworld forum/wiki commands.
|
|
25
26
|
Other packages — notably `coworld` — depend on it for auth.
|
|
26
27
|
|
|
27
28
|
## Install
|
|
@@ -43,8 +44,18 @@ uv run softmax set-token # store a token manually
|
|
|
43
44
|
uv run softmax player list # list your players (active one highlighted)
|
|
44
45
|
uv run softmax player use ply_... # act as a player in all auth-backed commands
|
|
45
46
|
uv run softmax player unset # revert to your main user credential
|
|
47
|
+
uv run softmax forum list softmax
|
|
48
|
+
uv run softmax forum post "Title" --file post.md
|
|
49
|
+
uv run softmax wiki read softmax guide/start
|
|
50
|
+
uv run softmax wiki edit softmax guide/start --file replacement.md
|
|
46
51
|
```
|
|
47
52
|
|
|
53
|
+
Forum and wiki commands use the active token as their identity. `softmax player use` selects a player token globally;
|
|
54
|
+
`softmax player unset` restores the user token. These commands have no impersonation option.
|
|
55
|
+
|
|
56
|
+
Wiki edits use compare-and-swap revisions. A conflict exits nonzero, writes `base.md`, `current.md`, and `proposed.md`,
|
|
57
|
+
then prints a `git merge-file` command and a retry command.
|
|
58
|
+
|
|
48
59
|
## Development
|
|
49
60
|
|
|
50
61
|
```bash
|
|
@@ -7,8 +7,11 @@ 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/forum.py
|
|
11
|
+
src/softmax/forum_wiki_api.py
|
|
10
12
|
src/softmax/perform_login.py
|
|
11
13
|
src/softmax/players.py
|
|
14
|
+
src/softmax/wiki.py
|
|
12
15
|
src/softmax_cli.egg-info/PKG-INFO
|
|
13
16
|
src/softmax_cli.egg-info/SOURCES.txt
|
|
14
17
|
src/softmax_cli.egg-info/dependency_links.txt
|
|
@@ -17,5 +20,7 @@ src/softmax_cli.egg-info/requires.txt
|
|
|
17
20
|
src/softmax_cli.egg-info/top_level.txt
|
|
18
21
|
tests/BUILD.bazel
|
|
19
22
|
tests/test_auth_login.py
|
|
23
|
+
tests/test_forum_wiki_api.py
|
|
24
|
+
tests/test_forum_wiki_cli.py
|
|
20
25
|
tests/test_player_cli.py
|
|
21
26
|
tests/test_python_api.py
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from softmax.forum_wiki_api import ForumWikiApi, WikiEditConflictResponse
|
|
8
|
+
|
|
9
|
+
POST = {
|
|
10
|
+
"id": "post_1",
|
|
11
|
+
"title": "Hello",
|
|
12
|
+
"author": {"type": "user", "user_id": "usr_1", "name": "Ada"},
|
|
13
|
+
"page": "main",
|
|
14
|
+
"content_format": "markdown",
|
|
15
|
+
"body": "Body",
|
|
16
|
+
"media": [],
|
|
17
|
+
"created_at": "2026-08-29T00:00:00Z",
|
|
18
|
+
"score": 1,
|
|
19
|
+
"vote_count": 1,
|
|
20
|
+
}
|
|
21
|
+
COMMENT = {
|
|
22
|
+
"id": "cmt_1",
|
|
23
|
+
"parent_id": None,
|
|
24
|
+
"author": {"type": "player", "player_id": "ply_1", "name": "Bot", "avatar_url": None},
|
|
25
|
+
"body": "Reply",
|
|
26
|
+
"created_at": "2026-08-29T00:01:00Z",
|
|
27
|
+
}
|
|
28
|
+
PAGE = {
|
|
29
|
+
"id": "wpg_1",
|
|
30
|
+
"wiki_id": "wiki_1",
|
|
31
|
+
"slug": "guide/start",
|
|
32
|
+
"title": "Start",
|
|
33
|
+
"current_revision_id": "wrv_1",
|
|
34
|
+
"created_at": "2026-08-29T00:00:00Z",
|
|
35
|
+
"current_revision": {
|
|
36
|
+
"id": "wrv_1",
|
|
37
|
+
"page_id": "wpg_1",
|
|
38
|
+
"body": "Base",
|
|
39
|
+
"author": {"type": "user", "user_id": "usr_1", "name": "Ada"},
|
|
40
|
+
"note": None,
|
|
41
|
+
"parent_revision_id": None,
|
|
42
|
+
"created_at": "2026-08-29T00:00:00Z",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_forum_api_uses_exact_paths_bearer_and_scriptable_models() -> None:
|
|
48
|
+
requests: list[httpx.Request] = []
|
|
49
|
+
|
|
50
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
51
|
+
requests.append(request)
|
|
52
|
+
path = request.url.path
|
|
53
|
+
if path == "/observatory/v2/forums/global/posts":
|
|
54
|
+
return httpx.Response(200, json={"entries": [POST], "next_cursor": None})
|
|
55
|
+
if path == "/observatory/v2/posts/post_1.md":
|
|
56
|
+
return httpx.Response(200, text="# Hello\n")
|
|
57
|
+
if path == "/observatory/v2/posts":
|
|
58
|
+
return httpx.Response(201, json=POST)
|
|
59
|
+
if path == "/observatory/v2/posts/post_1/comments":
|
|
60
|
+
return httpx.Response(201, json=COMMENT)
|
|
61
|
+
if path == "/observatory/v2/posts/post_1/vote":
|
|
62
|
+
return httpx.Response(200, json={"value": 1, "score": 2, "vote_count": 2})
|
|
63
|
+
if path == "/observatory/v2/posts/post_1/comments/cmt_1/vote":
|
|
64
|
+
return httpx.Response(200, json={"value": -1, "score": -1, "vote_count": None})
|
|
65
|
+
if path == "/observatory/v2/forums/global/search":
|
|
66
|
+
return httpx.Response(
|
|
67
|
+
200,
|
|
68
|
+
json={
|
|
69
|
+
"entries": [{"post": POST, "rank": 0.5}],
|
|
70
|
+
"next_cursor": None,
|
|
71
|
+
"result_quality": "exact_within_forum",
|
|
72
|
+
"candidate_limit": None,
|
|
73
|
+
"older_matches_may_be_omitted": False,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
if path == "/observatory/v2/forums/search":
|
|
77
|
+
return httpx.Response(
|
|
78
|
+
200,
|
|
79
|
+
json={
|
|
80
|
+
"entries": [{"post": POST, "rank": 0.5}],
|
|
81
|
+
"next_cursor": None,
|
|
82
|
+
"result_quality": "newest_5000_candidates",
|
|
83
|
+
"candidate_limit": 5000,
|
|
84
|
+
"older_matches_may_be_omitted": True,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
|
88
|
+
|
|
89
|
+
with ForumWikiApi(
|
|
90
|
+
server="https://softmax.test",
|
|
91
|
+
token="identity-token",
|
|
92
|
+
transport=httpx.MockTransport(handler),
|
|
93
|
+
) as api:
|
|
94
|
+
assert api.list_forum("global", sort="new", limit=10).entries[0].id == "post_1"
|
|
95
|
+
assert api.read_post("post_1") == "# Hello\n"
|
|
96
|
+
assert api.create_post(title="Hello", body="Body", idempotency_key="post-key").title == "Hello"
|
|
97
|
+
assert api.create_comment("post_1", body="Reply", idempotency_key="comment-key").id == "cmt_1"
|
|
98
|
+
assert api.vote("post_1", value=1).score == 2
|
|
99
|
+
assert api.vote("post_1", comment_id="cmt_1", value=-1).score == -1
|
|
100
|
+
assert api.search_forum("query", forum_slug="global").entries[0].post.id == "post_1"
|
|
101
|
+
assert api.search_forum("query").result_quality == "newest_5000_candidates"
|
|
102
|
+
|
|
103
|
+
assert [request.url.path for request in requests] == [
|
|
104
|
+
"/observatory/v2/forums/global/posts",
|
|
105
|
+
"/observatory/v2/posts/post_1.md",
|
|
106
|
+
"/observatory/v2/posts",
|
|
107
|
+
"/observatory/v2/posts/post_1/comments",
|
|
108
|
+
"/observatory/v2/posts/post_1/vote",
|
|
109
|
+
"/observatory/v2/posts/post_1/comments/cmt_1/vote",
|
|
110
|
+
"/observatory/v2/forums/global/search",
|
|
111
|
+
"/observatory/v2/forums/search",
|
|
112
|
+
]
|
|
113
|
+
assert all(request.headers["Authorization"] == "Bearer identity-token" for request in requests)
|
|
114
|
+
assert dict(requests[0].url.params) == {"sort": "new", "limit": "10"}
|
|
115
|
+
assert requests[-1].url.params["q"] == "query"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_wiki_api_uses_exact_paths_and_parses_typed_conflict() -> None:
|
|
119
|
+
requests: list[httpx.Request] = []
|
|
120
|
+
|
|
121
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
122
|
+
requests.append(request)
|
|
123
|
+
path = request.url.path
|
|
124
|
+
if path == "/observatory/v2/wikis/global/pages/guide/start" and request.method == "GET":
|
|
125
|
+
return httpx.Response(200, json=PAGE)
|
|
126
|
+
if path == "/observatory/v2/wikis/global/pages/guide/start" and request.method == "PUT":
|
|
127
|
+
return httpx.Response(
|
|
128
|
+
409,
|
|
129
|
+
json={
|
|
130
|
+
"detail": {
|
|
131
|
+
"type": "wiki_edit_conflict",
|
|
132
|
+
"current_revision_id": "wrv_2",
|
|
133
|
+
"current_body": "Current",
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
if path == "/observatory/v2/wiki-pages/wpg_1/revisions":
|
|
138
|
+
return httpx.Response(200, json={"entries": [PAGE["current_revision"]]})
|
|
139
|
+
if path == "/observatory/v2/wikis/global/search":
|
|
140
|
+
summary = {key: value for key, value in PAGE.items() if key != "current_revision"}
|
|
141
|
+
return httpx.Response(
|
|
142
|
+
200,
|
|
143
|
+
json={
|
|
144
|
+
"entries": [
|
|
145
|
+
{
|
|
146
|
+
"page": summary,
|
|
147
|
+
"current_author": {"type": "user", "user_id": "usr_1", "name": "Ada"},
|
|
148
|
+
"rank": 0.75,
|
|
149
|
+
}
|
|
150
|
+
],
|
|
151
|
+
"next_cursor": None,
|
|
152
|
+
"result_quality": "exact_within_wiki",
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
|
156
|
+
|
|
157
|
+
with ForumWikiApi(
|
|
158
|
+
server="https://softmax.test",
|
|
159
|
+
token="identity-token",
|
|
160
|
+
transport=httpx.MockTransport(handler),
|
|
161
|
+
) as api:
|
|
162
|
+
assert api.read_wiki_page("global", "guide/start").current_revision.body == "Base"
|
|
163
|
+
conflict = api.edit_wiki_page(
|
|
164
|
+
"global",
|
|
165
|
+
"guide/start",
|
|
166
|
+
title="Start",
|
|
167
|
+
body="Proposed",
|
|
168
|
+
base_revision_id="wrv_1",
|
|
169
|
+
idempotency_key="edit-key",
|
|
170
|
+
)
|
|
171
|
+
assert isinstance(conflict, WikiEditConflictResponse)
|
|
172
|
+
assert conflict.detail.current_body == "Current"
|
|
173
|
+
assert api.wiki_history("wpg_1").entries[0].id == "wrv_1"
|
|
174
|
+
assert api.search_wiki("global", "query").entries[0].page.id == "wpg_1"
|
|
175
|
+
|
|
176
|
+
assert [request.url.path for request in requests] == [
|
|
177
|
+
"/observatory/v2/wikis/global/pages/guide/start",
|
|
178
|
+
"/observatory/v2/wikis/global/pages/guide/start",
|
|
179
|
+
"/observatory/v2/wiki-pages/wpg_1/revisions",
|
|
180
|
+
"/observatory/v2/wikis/global/search",
|
|
181
|
+
]
|
|
182
|
+
assert all(request.headers["Authorization"] == "Bearer identity-token" for request in requests)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_mutations_retain_idempotency_keys_across_retries() -> None:
|
|
186
|
+
bodies: dict[str, list[dict[str, object]]] = {}
|
|
187
|
+
|
|
188
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
189
|
+
path = request.url.path
|
|
190
|
+
path_bodies = bodies.setdefault(path, [])
|
|
191
|
+
path_bodies.append(json.loads(request.content))
|
|
192
|
+
if len(path_bodies) == 1:
|
|
193
|
+
return httpx.Response(503, json={"detail": "retry"})
|
|
194
|
+
if path == "/observatory/v2/posts":
|
|
195
|
+
return httpx.Response(201, json=POST)
|
|
196
|
+
if path == "/observatory/v2/posts/post_1/comments":
|
|
197
|
+
return httpx.Response(201, json=COMMENT)
|
|
198
|
+
if path == "/observatory/v2/wikis/global/pages/guide/start":
|
|
199
|
+
summary = {key: value for key, value in PAGE.items() if key != "current_revision"}
|
|
200
|
+
return httpx.Response(200, json={"page": summary, "revision": PAGE["current_revision"]})
|
|
201
|
+
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
|
202
|
+
|
|
203
|
+
with ForumWikiApi(
|
|
204
|
+
server="https://softmax.test",
|
|
205
|
+
token="identity-token",
|
|
206
|
+
transport=httpx.MockTransport(handler),
|
|
207
|
+
) as api:
|
|
208
|
+
api.create_post(title="Hello", body="Body", idempotency_key="stable-key")
|
|
209
|
+
api.create_comment("post_1", body="Reply", idempotency_key="comment-key")
|
|
210
|
+
api.edit_wiki_page(
|
|
211
|
+
"global",
|
|
212
|
+
"guide/start",
|
|
213
|
+
title="Start",
|
|
214
|
+
body="Proposed",
|
|
215
|
+
base_revision_id="wrv_1",
|
|
216
|
+
idempotency_key="edit-key",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
assert [body["idempotency_key"] for body in bodies["/observatory/v2/posts"]] == ["stable-key", "stable-key"]
|
|
220
|
+
assert [body["idempotency_key"] for body in bodies["/observatory/v2/posts/post_1/comments"]] == [
|
|
221
|
+
"comment-key",
|
|
222
|
+
"comment-key",
|
|
223
|
+
]
|
|
224
|
+
assert [body["idempotency_key"] for body in bodies["/observatory/v2/wikis/global/pages/guide/start"]] == [
|
|
225
|
+
"edit-key",
|
|
226
|
+
"edit-key",
|
|
227
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from softmax.cli import app
|
|
11
|
+
from softmax.forum_wiki_api import (
|
|
12
|
+
PostFeedPage,
|
|
13
|
+
PostPublic,
|
|
14
|
+
WikiEditConflictDetail,
|
|
15
|
+
WikiEditConflictResponse,
|
|
16
|
+
WikiPagePublic,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
POST = {
|
|
20
|
+
"id": "post_1",
|
|
21
|
+
"title": "Hello",
|
|
22
|
+
"author": {"type": "user", "user_id": "usr_1", "name": "Ada"},
|
|
23
|
+
"page": "main",
|
|
24
|
+
"content_format": "markdown",
|
|
25
|
+
"body": "Body",
|
|
26
|
+
"media": [],
|
|
27
|
+
"created_at": "2026-08-29T00:00:00Z",
|
|
28
|
+
"score": 1,
|
|
29
|
+
"vote_count": 1,
|
|
30
|
+
}
|
|
31
|
+
PAGE = {
|
|
32
|
+
"id": "wpg_1",
|
|
33
|
+
"wiki_id": "wiki_1",
|
|
34
|
+
"slug": "guide/start",
|
|
35
|
+
"title": "Start",
|
|
36
|
+
"current_revision_id": "wrv_1",
|
|
37
|
+
"created_at": "2026-08-29T00:00:00Z",
|
|
38
|
+
"current_revision": {
|
|
39
|
+
"id": "wrv_1",
|
|
40
|
+
"page_id": "wpg_1",
|
|
41
|
+
"body": "Base",
|
|
42
|
+
"author": {"type": "user", "user_id": "usr_1", "name": "Ada"},
|
|
43
|
+
"note": None,
|
|
44
|
+
"parent_revision_id": None,
|
|
45
|
+
"created_at": "2026-08-29T00:00:00Z",
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FakeApi:
|
|
51
|
+
def __enter__(self) -> "FakeApi":
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, *_: object) -> None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def list_forum(self, *_: object, **__: object) -> PostFeedPage:
|
|
58
|
+
return PostFeedPage(entries=[PostPublic.model_validate(POST)], next_cursor=None)
|
|
59
|
+
|
|
60
|
+
def read_wiki_page(self, *_: object, **__: object) -> WikiPagePublic:
|
|
61
|
+
return WikiPagePublic.model_validate(PAGE)
|
|
62
|
+
|
|
63
|
+
def edit_wiki_page(self, *_: object, **__: object) -> WikiEditConflictResponse:
|
|
64
|
+
return WikiEditConflictResponse(
|
|
65
|
+
detail=WikiEditConflictDetail(
|
|
66
|
+
current_revision_id="wrv_2",
|
|
67
|
+
current_body="Current body",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture(autouse=True)
|
|
73
|
+
def _fake_api(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
74
|
+
monkeypatch.setattr("softmax.forum.ForumWikiApi.from_current_token", lambda server: FakeApi())
|
|
75
|
+
monkeypatch.setattr("softmax.wiki.ForumWikiApi.from_current_token", lambda server: FakeApi())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_help_documents_token_identity_and_has_no_impersonation_flag() -> None:
|
|
79
|
+
for subapp in ("forum", "wiki"):
|
|
80
|
+
result = CliRunner().invoke(app, [subapp, "--help"])
|
|
81
|
+
assert result.exit_code == 0, result.output
|
|
82
|
+
assert "active token" in result.output
|
|
83
|
+
assert "--as" not in result.output
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_vote_help_renders_numeric_values() -> None:
|
|
87
|
+
result = CliRunner().invoke(app, ["forum", "vote", "--help"])
|
|
88
|
+
|
|
89
|
+
assert result.exit_code == 0, result.output
|
|
90
|
+
assert "-1, 0, or 1" in result.output
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_forum_list_json_is_scriptable() -> None:
|
|
94
|
+
result = CliRunner().invoke(app, ["forum", "list", "global", "--json"])
|
|
95
|
+
|
|
96
|
+
assert result.exit_code == 0, result.output
|
|
97
|
+
payload = json.loads(result.output)
|
|
98
|
+
assert payload["entries"][0]["id"] == "post_1"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_wiki_edit_conflict_writes_three_files_and_prints_merge_command(
|
|
102
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
103
|
+
tmp_path: Path,
|
|
104
|
+
) -> None:
|
|
105
|
+
proposed = tmp_path / "proposed-input.md"
|
|
106
|
+
proposed.write_text("Proposed body")
|
|
107
|
+
conflict_dir = tmp_path / "conflict"
|
|
108
|
+
|
|
109
|
+
def make_conflict_dir(*_: object, **__: object) -> str:
|
|
110
|
+
conflict_dir.mkdir()
|
|
111
|
+
return str(conflict_dir)
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr("softmax.wiki.tempfile.mkdtemp", make_conflict_dir)
|
|
114
|
+
|
|
115
|
+
result = CliRunner().invoke(
|
|
116
|
+
app,
|
|
117
|
+
[
|
|
118
|
+
"wiki",
|
|
119
|
+
"edit",
|
|
120
|
+
"global",
|
|
121
|
+
"guide/start",
|
|
122
|
+
"--file",
|
|
123
|
+
str(proposed),
|
|
124
|
+
"--title",
|
|
125
|
+
"Guide's start",
|
|
126
|
+
"--note",
|
|
127
|
+
"Resolve conflict; keep examples",
|
|
128
|
+
],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
assert result.exit_code != 0
|
|
132
|
+
assert (conflict_dir / "base.md").read_text() == "Base"
|
|
133
|
+
assert (conflict_dir / "current.md").read_text() == "Current body"
|
|
134
|
+
assert (conflict_dir / "proposed.md").read_text() == "Proposed body"
|
|
135
|
+
assert "git merge-file" in result.output
|
|
136
|
+
assert str(conflict_dir / "proposed.md") in result.output
|
|
137
|
+
retry_command = next(line for line in result.output.splitlines() if line.startswith("softmax wiki edit"))
|
|
138
|
+
assert shlex.split(retry_command) == [
|
|
139
|
+
"softmax",
|
|
140
|
+
"wiki",
|
|
141
|
+
"edit",
|
|
142
|
+
"global",
|
|
143
|
+
"guide/start",
|
|
144
|
+
"--file",
|
|
145
|
+
str(conflict_dir / "proposed.md"),
|
|
146
|
+
"--server",
|
|
147
|
+
"https://softmax.com/api",
|
|
148
|
+
"--title",
|
|
149
|
+
"Guide's start",
|
|
150
|
+
"--note",
|
|
151
|
+
"Resolve conflict; keep examples",
|
|
152
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|