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.
Files changed (28) hide show
  1. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/AGENTS.md +5 -1
  2. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/CLAUDE.md +5 -1
  3. {softmax_cli-0.26.30/src/softmax_cli.egg-info → softmax_cli-0.26.32}/PKG-INFO +13 -2
  4. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/README.md +12 -1
  5. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/cli.py +4 -0
  6. softmax_cli-0.26.32/src/softmax/forum.py +136 -0
  7. softmax_cli-0.26.32/src/softmax/forum_wiki_api.py +361 -0
  8. softmax_cli-0.26.32/src/softmax/wiki.py +148 -0
  9. {softmax_cli-0.26.30 → softmax_cli-0.26.32/src/softmax_cli.egg-info}/PKG-INFO +13 -2
  10. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/SOURCES.txt +5 -0
  11. softmax_cli-0.26.32/tests/test_forum_wiki_api.py +227 -0
  12. softmax_cli-0.26.32/tests/test_forum_wiki_cli.py +152 -0
  13. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/BUILD.bazel +0 -0
  14. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/pyproject.toml +0 -0
  15. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/setup.cfg +0 -0
  16. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/__init__.py +0 -0
  17. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/_console.py +0 -0
  18. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/auth.py +0 -0
  19. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/perform_login.py +0 -0
  20. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax/players.py +0 -0
  21. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
  22. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/entry_points.txt +0 -0
  23. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/requires.txt +0 -0
  24. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/src/softmax_cli.egg-info/top_level.txt +0 -0
  25. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/tests/BUILD.bazel +0 -0
  26. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/tests/test_auth_login.py +0 -0
  27. {softmax_cli-0.26.30 → softmax_cli-0.26.32}/tests/test_player_cli.py +0 -0
  28. {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` subapp via `add_typer`.
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` subapp via `add_typer`.
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.30
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, and player identity switching.
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, and player identity switching.
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.30
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, and player identity switching.
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