bookstack-cli 0.1.0__py3-none-any.whl
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.
- bookstack_cli/__init__.py +3 -0
- bookstack_cli/client.py +211 -0
- bookstack_cli/config.py +131 -0
- bookstack_cli/exceptions.py +45 -0
- bookstack_cli/main.py +840 -0
- bookstack_cli/models.py +276 -0
- bookstack_cli/resources/__init__.py +1 -0
- bookstack_cli/resources/attachments.py +90 -0
- bookstack_cli/resources/books.py +64 -0
- bookstack_cli/resources/chapters.py +46 -0
- bookstack_cli/resources/pages.py +365 -0
- bookstack_cli/resources/revisions.py +30 -0
- bookstack_cli/resources/roles.py +33 -0
- bookstack_cli/resources/search.py +21 -0
- bookstack_cli/resources/shelves.py +55 -0
- bookstack_cli/resources/tags.py +15 -0
- bookstack_cli/resources/users.py +39 -0
- bookstack_cli-0.1.0.dist-info/METADATA +227 -0
- bookstack_cli-0.1.0.dist-info/RECORD +22 -0
- bookstack_cli-0.1.0.dist-info/WHEEL +5 -0
- bookstack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bookstack_cli-0.1.0.dist-info/top_level.txt +1 -0
bookstack_cli/main.py
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
"""Main CLI entry point — Typer app with subcommands per resource."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator, Coroutine
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from bookstack_cli.client import BookStackClient
|
|
12
|
+
from bookstack_cli.config import get_config, save_config
|
|
13
|
+
from bookstack_cli.exceptions import BookStackError
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="bookstack",
|
|
17
|
+
help="CLI for BookStack wiki API — built for coding agents.",
|
|
18
|
+
pretty_exceptions_enable=False,
|
|
19
|
+
)
|
|
20
|
+
config_app = typer.Typer(help="Manage connection config.")
|
|
21
|
+
shelves_app = typer.Typer(help="Manage bookshelves.")
|
|
22
|
+
books_app = typer.Typer(help="Manage books.")
|
|
23
|
+
chapters_app = typer.Typer(help="Manage chapters.")
|
|
24
|
+
pages_app = typer.Typer(help="Manage pages.")
|
|
25
|
+
attachments_app = typer.Typer(help="Manage attachments.")
|
|
26
|
+
users_app = typer.Typer(help="Manage users (admin).")
|
|
27
|
+
roles_app = typer.Typer(help="Manage roles (admin).")
|
|
28
|
+
search_app = typer.Typer(help="Search content.")
|
|
29
|
+
|
|
30
|
+
app.add_typer(config_app, name="config")
|
|
31
|
+
app.add_typer(shelves_app, name="shelves")
|
|
32
|
+
app.add_typer(books_app, name="books")
|
|
33
|
+
app.add_typer(chapters_app, name="chapters")
|
|
34
|
+
app.add_typer(pages_app, name="pages")
|
|
35
|
+
app.add_typer(attachments_app, name="attachments")
|
|
36
|
+
app.add_typer(users_app, name="users")
|
|
37
|
+
app.add_typer(roles_app, name="roles")
|
|
38
|
+
app.add_typer(search_app, name="search")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Event-loop helpers
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_loop: asyncio.AbstractEventLoop | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_loop() -> asyncio.AbstractEventLoop:
|
|
49
|
+
"""Get or create a global event loop for sync→async bridging."""
|
|
50
|
+
global _loop
|
|
51
|
+
if _loop is None or _loop.is_closed():
|
|
52
|
+
try:
|
|
53
|
+
_loop = asyncio.get_event_loop()
|
|
54
|
+
except RuntimeError:
|
|
55
|
+
_loop = asyncio.new_event_loop()
|
|
56
|
+
asyncio.set_event_loop(_loop)
|
|
57
|
+
return _loop
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run(coro: Coroutine[Any, Any, Any]) -> Any:
|
|
61
|
+
"""Run an async coroutine synchronously."""
|
|
62
|
+
return _get_loop().run_until_complete(coro)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _collect(ait: AsyncIterator[Any]) -> list[dict[str, Any]]:
|
|
66
|
+
"""Collect all items from an async iterator into a list of dicts."""
|
|
67
|
+
loop = _get_loop()
|
|
68
|
+
items: list[Any] = []
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
items.append(loop.run_until_complete(ait.__anext__()))
|
|
72
|
+
except StopAsyncIteration:
|
|
73
|
+
break
|
|
74
|
+
return [i.model_dump(mode="json") if hasattr(i, "model_dump") else i for i in items]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# JSON output
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _print(obj: Any) -> None:
|
|
83
|
+
"""Print as JSON for agent consumption."""
|
|
84
|
+
import json as j
|
|
85
|
+
|
|
86
|
+
if isinstance(obj, AsyncIterator):
|
|
87
|
+
print(j.dumps(_collect(obj), indent=2))
|
|
88
|
+
elif hasattr(obj, "model_dump"):
|
|
89
|
+
print(j.dumps(obj.model_dump(mode="json"), indent=2))
|
|
90
|
+
elif isinstance(obj, list):
|
|
91
|
+
print(
|
|
92
|
+
j.dumps(
|
|
93
|
+
[i.model_dump(mode="json") if hasattr(i, "model_dump") else i for i in obj],
|
|
94
|
+
indent=2,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
elif isinstance(obj, dict):
|
|
98
|
+
print(j.dumps(obj, indent=2))
|
|
99
|
+
else:
|
|
100
|
+
print(j.dumps({"result": str(obj)}, indent=2))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _client() -> BookStackClient:
|
|
104
|
+
"""Create a client from env/config."""
|
|
105
|
+
return BookStackClient()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Config
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@config_app.command("show")
|
|
114
|
+
def config_show():
|
|
115
|
+
"""Show current connection config."""
|
|
116
|
+
try:
|
|
117
|
+
cfg = get_config()
|
|
118
|
+
info = {"url": cfg.url, "token_id": cfg.token_id}
|
|
119
|
+
if cfg.resolve_url and cfg.resolve_url != cfg.url:
|
|
120
|
+
info["resolve_url"] = cfg.resolve_url
|
|
121
|
+
_print(info)
|
|
122
|
+
except BookStackError as e:
|
|
123
|
+
_print({"error": str(e)})
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("auth")
|
|
128
|
+
def auth_cmd(
|
|
129
|
+
url: str = typer.Option(..., prompt=True, help="BookStack API base URL (internal)"),
|
|
130
|
+
token_id: str = typer.Option(..., prompt=True, help="API token ID"),
|
|
131
|
+
token_secret: str = typer.Option(..., prompt=True, hide_input=True, help="API token secret"),
|
|
132
|
+
resolve_url: str | None = typer.Option(None, "--resolve-url",
|
|
133
|
+
help="Public web URL (if different from API URL, e.g. behind OAuth proxy)"),
|
|
134
|
+
):
|
|
135
|
+
"""Save connection credentials to ~/.config/bookstack-cli/config.toml."""
|
|
136
|
+
path = save_config(url, token_id, token_secret, resolve_url=resolve_url)
|
|
137
|
+
_print({"ok": True, "message": f"Config saved to {path}"})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Shelf commands
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@shelves_app.command("list")
|
|
146
|
+
def shelves_list():
|
|
147
|
+
"""List all shelves."""
|
|
148
|
+
with _client() as c:
|
|
149
|
+
import bookstack_cli.resources.shelves as r
|
|
150
|
+
|
|
151
|
+
_print(_collect(r.list_shelves(c)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@shelves_app.command("get")
|
|
155
|
+
def shelves_get(id: int = typer.Argument(..., help="Shelf ID")):
|
|
156
|
+
"""Get a shelf by ID."""
|
|
157
|
+
with _client() as c:
|
|
158
|
+
import bookstack_cli.resources.shelves as r
|
|
159
|
+
|
|
160
|
+
_print(_run(r.get_shelf(c, id)))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@shelves_app.command("create")
|
|
164
|
+
def shelves_create(
|
|
165
|
+
name: str = typer.Argument(..., help="Shelf name"),
|
|
166
|
+
description: str = "",
|
|
167
|
+
tags: str | None = typer.Option(None, "--tags", help="JSON array of tags"),
|
|
168
|
+
):
|
|
169
|
+
"""Create a new shelf."""
|
|
170
|
+
with _client() as c:
|
|
171
|
+
import bookstack_cli.resources.shelves as r
|
|
172
|
+
from bookstack_cli.models import ShelfCreate
|
|
173
|
+
|
|
174
|
+
kwargs: dict[str, Any] = {"name": name, "description": description}
|
|
175
|
+
if tags:
|
|
176
|
+
import json as _json
|
|
177
|
+
kwargs["tags"] = _json.loads(tags)
|
|
178
|
+
|
|
179
|
+
_print(_run(r.create_shelf(c, ShelfCreate(**kwargs))))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@shelves_app.command("delete")
|
|
183
|
+
def shelves_delete(id: int = typer.Argument(..., help="Shelf ID")):
|
|
184
|
+
"""Delete a shelf."""
|
|
185
|
+
with _client() as c:
|
|
186
|
+
import bookstack_cli.resources.shelves as r
|
|
187
|
+
|
|
188
|
+
_run(r.delete_shelf(c, id))
|
|
189
|
+
_print({"ok": True, "id": id})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@shelves_app.command("upload-cover")
|
|
193
|
+
def shelves_upload_cover(
|
|
194
|
+
id: int = typer.Argument(..., help="Shelf ID"),
|
|
195
|
+
file: Path = typer.Option(..., "--file", help="Path to cover image"),
|
|
196
|
+
):
|
|
197
|
+
"""Upload a cover image for a shelf."""
|
|
198
|
+
file_content = file.read_bytes()
|
|
199
|
+
with _client() as c:
|
|
200
|
+
import bookstack_cli.resources.shelves as r
|
|
201
|
+
|
|
202
|
+
result = _run(r.upload_shelf_cover(c, id, file_content=file_content, filename=file.name))
|
|
203
|
+
_print({"ok": True, "shelf_id": id, "image_id": result.get("image_id")})
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@shelves_app.command("update")
|
|
207
|
+
def shelves_update(
|
|
208
|
+
id: int = typer.Argument(..., help="Shelf ID"),
|
|
209
|
+
name: str = typer.Argument(..., help="New shelf name"),
|
|
210
|
+
description: str = "",
|
|
211
|
+
books: str | None = typer.Option(None, "--books", help="Comma-separated book IDs to assign to shelf"),
|
|
212
|
+
tags: str | None = typer.Option(None, "--tags", help="JSON array of tags e.g. '[{\"name\":\"k\",\"value\":\"v\"}]'"),
|
|
213
|
+
):
|
|
214
|
+
"""Update a shelf."""
|
|
215
|
+
with _client() as c:
|
|
216
|
+
import bookstack_cli.resources.shelves as r
|
|
217
|
+
from bookstack_cli.models import ShelfCreate
|
|
218
|
+
|
|
219
|
+
kwargs: dict[str, Any] = {"name": name, "description": description}
|
|
220
|
+
if books:
|
|
221
|
+
kwargs["books"] = [int(b.strip()) for b in books.split(",") if b.strip()]
|
|
222
|
+
if tags:
|
|
223
|
+
import json as _json
|
|
224
|
+
kwargs["tags"] = _json.loads(tags)
|
|
225
|
+
|
|
226
|
+
_print(_run(r.update_shelf(c, id, ShelfCreate(**kwargs))))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Book commands
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@books_app.command("list")
|
|
235
|
+
def books_list(
|
|
236
|
+
sort: str = typer.Option(None, help="Field to sort by (name, created_at, etc.)"),
|
|
237
|
+
order: str = typer.Option(None, help="Sort order (asc, desc)"),
|
|
238
|
+
):
|
|
239
|
+
"""List all books."""
|
|
240
|
+
with _client() as c:
|
|
241
|
+
import bookstack_cli.resources.books as r
|
|
242
|
+
|
|
243
|
+
_print(_collect(r.list_books(c, sort=sort, order=order)))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@books_app.command("get")
|
|
247
|
+
def books_get(id: int = typer.Argument(..., help="Book ID")):
|
|
248
|
+
"""Get a book by ID."""
|
|
249
|
+
with _client() as c:
|
|
250
|
+
import bookstack_cli.resources.books as r
|
|
251
|
+
|
|
252
|
+
_print(_run(r.get_book(c, id)))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@books_app.command("create")
|
|
256
|
+
def books_create(
|
|
257
|
+
name: str = typer.Argument(..., help="Book name"),
|
|
258
|
+
description: str = "",
|
|
259
|
+
tags: str | None = typer.Option(None, "--tags", help="JSON array of tags"),
|
|
260
|
+
):
|
|
261
|
+
"""Create a new book."""
|
|
262
|
+
with _client() as c:
|
|
263
|
+
import bookstack_cli.resources.books as r
|
|
264
|
+
from bookstack_cli.models import BookCreate
|
|
265
|
+
|
|
266
|
+
kwargs: dict[str, Any] = {"name": name, "description": description}
|
|
267
|
+
if tags:
|
|
268
|
+
import json as _json
|
|
269
|
+
kwargs["tags"] = _json.loads(tags)
|
|
270
|
+
|
|
271
|
+
_print(_run(r.create_book(c, BookCreate(**kwargs))))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@books_app.command("delete")
|
|
275
|
+
def books_delete(id: int = typer.Argument(..., help="Book ID")):
|
|
276
|
+
"""Delete a book."""
|
|
277
|
+
with _client() as c:
|
|
278
|
+
import bookstack_cli.resources.books as r
|
|
279
|
+
|
|
280
|
+
_run(r.delete_book(c, id))
|
|
281
|
+
_print({"ok": True, "id": id})
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@books_app.command("upload-cover")
|
|
285
|
+
def books_upload_cover(
|
|
286
|
+
id: int = typer.Argument(..., help="Book ID"),
|
|
287
|
+
file: Path = typer.Option(..., "--file", help="Path to cover image"),
|
|
288
|
+
):
|
|
289
|
+
"""Upload a cover image for a book."""
|
|
290
|
+
file_content = file.read_bytes()
|
|
291
|
+
with _client() as c:
|
|
292
|
+
import bookstack_cli.resources.books as r
|
|
293
|
+
|
|
294
|
+
result = _run(r.upload_book_cover(c, id, file_content=file_content, filename=file.name))
|
|
295
|
+
_print({"ok": True, "book_id": id, "image_id": result.get("image_id")})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@books_app.command("update")
|
|
299
|
+
def books_update(
|
|
300
|
+
id: int = typer.Argument(..., help="Book ID"),
|
|
301
|
+
name: str = typer.Argument(..., help="New book name"),
|
|
302
|
+
description: str = "",
|
|
303
|
+
tags: str | None = typer.Option(None, "--tags", help="JSON array of tags e.g. '[{\"name\":\"k\",\"value\":\"v\"}]'"),
|
|
304
|
+
):
|
|
305
|
+
"""Update a book."""
|
|
306
|
+
with _client() as c:
|
|
307
|
+
import bookstack_cli.resources.books as r
|
|
308
|
+
from bookstack_cli.models import BookCreate
|
|
309
|
+
|
|
310
|
+
kwargs: dict[str, Any] = {"name": name, "description": description}
|
|
311
|
+
if tags:
|
|
312
|
+
import json as _json
|
|
313
|
+
kwargs["tags"] = _json.loads(tags)
|
|
314
|
+
|
|
315
|
+
_print(_run(r.update_book(c, id, BookCreate(**kwargs))))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
# Chapter commands
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@chapters_app.command("list")
|
|
324
|
+
def chapters_list(book_id: int = typer.Option(None, help="Filter by book ID")):
|
|
325
|
+
"""List chapters."""
|
|
326
|
+
with _client() as c:
|
|
327
|
+
import bookstack_cli.resources.chapters as r
|
|
328
|
+
|
|
329
|
+
_print(_collect(r.list_chapters(c, book_id=book_id)))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@chapters_app.command("get")
|
|
333
|
+
def chapters_get(id: int = typer.Argument(..., help="Chapter ID")):
|
|
334
|
+
"""Get a chapter by ID."""
|
|
335
|
+
with _client() as c:
|
|
336
|
+
import bookstack_cli.resources.chapters as r
|
|
337
|
+
|
|
338
|
+
_print(_run(r.get_chapter(c, id)))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@chapters_app.command("create")
|
|
342
|
+
def chapters_create(
|
|
343
|
+
book_id: int = typer.Option(..., help="Parent book ID"),
|
|
344
|
+
name: str = typer.Argument(..., help="Chapter name"),
|
|
345
|
+
description: str = "",
|
|
346
|
+
tags: str | None = typer.Option(None, "--tags", help="JSON array of tags"),
|
|
347
|
+
):
|
|
348
|
+
"""Create a new chapter."""
|
|
349
|
+
with _client() as c:
|
|
350
|
+
import bookstack_cli.resources.chapters as r
|
|
351
|
+
from bookstack_cli.models import ChapterCreate
|
|
352
|
+
|
|
353
|
+
kwargs: dict[str, Any] = {"book_id": book_id, "name": name, "description": description}
|
|
354
|
+
if tags:
|
|
355
|
+
import json as _json
|
|
356
|
+
kwargs["tags"] = _json.loads(tags)
|
|
357
|
+
|
|
358
|
+
_print(_run(r.create_chapter(c, ChapterCreate(**kwargs))))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@chapters_app.command("delete")
|
|
362
|
+
def chapters_delete(id: int = typer.Argument(..., help="Chapter ID")):
|
|
363
|
+
"""Delete a chapter."""
|
|
364
|
+
with _client() as c:
|
|
365
|
+
import bookstack_cli.resources.chapters as r
|
|
366
|
+
|
|
367
|
+
_run(r.delete_chapter(c, id))
|
|
368
|
+
_print({"ok": True, "id": id})
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@chapters_app.command("update")
|
|
372
|
+
def chapters_update(
|
|
373
|
+
id: int = typer.Argument(..., help="Chapter ID"),
|
|
374
|
+
name: str = typer.Argument(..., help="New chapter name"),
|
|
375
|
+
description: str = "",
|
|
376
|
+
book_id: int = typer.Option(..., "--book-id", help="Parent book ID"),
|
|
377
|
+
tags: str | None = typer.Option(None, "--tags", help="JSON array of tags e.g. '[{\"name\":\"k\",\"value\":\"v\"}]'"),
|
|
378
|
+
):
|
|
379
|
+
"""Update a chapter."""
|
|
380
|
+
with _client() as c:
|
|
381
|
+
import bookstack_cli.resources.chapters as r
|
|
382
|
+
from bookstack_cli.models import ChapterCreate
|
|
383
|
+
|
|
384
|
+
kwargs: dict[str, Any] = {"book_id": book_id, "name": name, "description": description}
|
|
385
|
+
if tags:
|
|
386
|
+
import json as _json
|
|
387
|
+
kwargs["tags"] = _json.loads(tags)
|
|
388
|
+
|
|
389
|
+
_print(_run(r.update_chapter(c, id, ChapterCreate(**kwargs))))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
# Page commands
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@pages_app.command("list")
|
|
398
|
+
def pages_list(
|
|
399
|
+
book_id: int = typer.Option(None, help="Filter by book ID"),
|
|
400
|
+
chapter_id: int = typer.Option(None, help="Filter by chapter ID"),
|
|
401
|
+
drafts: bool = typer.Option(False, "--drafts", help="Include drafts"),
|
|
402
|
+
):
|
|
403
|
+
"""List pages."""
|
|
404
|
+
with _client() as c:
|
|
405
|
+
import bookstack_cli.resources.pages as r
|
|
406
|
+
|
|
407
|
+
_print(
|
|
408
|
+
_collect(r.list_pages(c, book_id=book_id, chapter_id=chapter_id, include_drafts=drafts))
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@pages_app.command("get")
|
|
413
|
+
def pages_get(id: int = typer.Argument(..., help="Page ID")):
|
|
414
|
+
"""Get a page by ID."""
|
|
415
|
+
with _client() as c:
|
|
416
|
+
import bookstack_cli.resources.pages as r
|
|
417
|
+
|
|
418
|
+
_print(_run(r.get_page(c, id)))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@pages_app.command("create")
|
|
422
|
+
def pages_create(
|
|
423
|
+
book_id: int = typer.Option(..., help="Parent book ID"),
|
|
424
|
+
name: str = typer.Argument(..., help="Page title"),
|
|
425
|
+
markdown: str = typer.Option("", help="Content in markdown"),
|
|
426
|
+
html: str = typer.Option("", help="Content in HTML (ignored if markdown given)"),
|
|
427
|
+
markdown_file: Path | None = typer.Option(None, "--markdown-file",
|
|
428
|
+
help="Read markdown from file"),
|
|
429
|
+
html_file: Path | None = typer.Option(None, "--html-file",
|
|
430
|
+
help="Read HTML from file"),
|
|
431
|
+
chapter_id: int = typer.Option(None, help="Parent chapter ID"),
|
|
432
|
+
):
|
|
433
|
+
"""Create a new page.
|
|
434
|
+
|
|
435
|
+
Content can be provided via:
|
|
436
|
+
- --markdown or --html flags (inline)
|
|
437
|
+
- --markdown-file or --html-file flags (read from file)
|
|
438
|
+
- stdin pipe (auto-detected when stdin is not a TTY)
|
|
439
|
+
"""
|
|
440
|
+
import sys
|
|
441
|
+
|
|
442
|
+
if markdown_file:
|
|
443
|
+
markdown = markdown_file.read_text()
|
|
444
|
+
elif html_file:
|
|
445
|
+
html = html_file.read_text()
|
|
446
|
+
elif not markdown and not html and not sys.stdin.isatty():
|
|
447
|
+
stdin_content = sys.stdin.read()
|
|
448
|
+
if stdin_content.strip():
|
|
449
|
+
markdown = stdin_content
|
|
450
|
+
|
|
451
|
+
# Only pass non-empty content — empty string triggers API validation error
|
|
452
|
+
kwargs: dict[str, Any] = {"book_id": book_id, "name": name}
|
|
453
|
+
if chapter_id is not None:
|
|
454
|
+
kwargs["chapter_id"] = chapter_id
|
|
455
|
+
if markdown:
|
|
456
|
+
kwargs["markdown"] = markdown
|
|
457
|
+
if html and not markdown:
|
|
458
|
+
kwargs["html"] = html
|
|
459
|
+
|
|
460
|
+
with _client() as c:
|
|
461
|
+
import bookstack_cli.resources.pages as r
|
|
462
|
+
from bookstack_cli.models import PageCreate
|
|
463
|
+
|
|
464
|
+
_print(_run(r.create_page(c, PageCreate(**kwargs))))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@pages_app.command("move")
|
|
468
|
+
def pages_move(
|
|
469
|
+
id: int = typer.Argument(..., help="Page ID"),
|
|
470
|
+
book_id: int = typer.Option(..., "--book-id", help="Target book ID"),
|
|
471
|
+
chapter_id: int | None = typer.Option(None, "--chapter-id", help="Target chapter ID (omit for book root)"),
|
|
472
|
+
):
|
|
473
|
+
"""Move a page to a different book or chapter."""
|
|
474
|
+
with _client() as c:
|
|
475
|
+
import bookstack_cli.resources.pages as r
|
|
476
|
+
from bookstack_cli.models import PageMove
|
|
477
|
+
|
|
478
|
+
kwargs: dict[str, Any] = {"book_id": book_id}
|
|
479
|
+
if chapter_id is not None:
|
|
480
|
+
kwargs["chapter_id"] = chapter_id
|
|
481
|
+
|
|
482
|
+
_print(_run(r.move_page(c, id, PageMove(**kwargs))))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@pages_app.command("copy")
|
|
486
|
+
def pages_copy(
|
|
487
|
+
id: int = typer.Argument(..., help="Source page ID"),
|
|
488
|
+
book_id: int = typer.Option(..., "--book-id", help="Target book ID"),
|
|
489
|
+
chapter_id: int | None = typer.Option(None, "--chapter-id", help="Target chapter ID (omit for book root)"),
|
|
490
|
+
name: str | None = typer.Option(None, "--name", help="New page name (default: 'original (copy)')"),
|
|
491
|
+
):
|
|
492
|
+
"""Copy a page to a different book or chapter.
|
|
493
|
+
|
|
494
|
+
Creates a new page with the same markdown content and tags in the target
|
|
495
|
+
location. The original page remains unchanged.
|
|
496
|
+
"""
|
|
497
|
+
with _client() as c:
|
|
498
|
+
import bookstack_cli.resources.pages as r
|
|
499
|
+
|
|
500
|
+
_print(_run(r.copy_page(c, id, book_id=book_id, chapter_id=chapter_id, new_name=name)))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@pages_app.command("import")
|
|
504
|
+
def pages_import(
|
|
505
|
+
file: Path = typer.Option(..., "--file", help="Path to markdown file"),
|
|
506
|
+
name: str | None = typer.Option(None, "--name", help="Page name (default: filename)"),
|
|
507
|
+
book_id: int | None = typer.Option(None, "--book-id", help="Parent book ID (required for new page)"),
|
|
508
|
+
page_id: int | None = typer.Option(None, "--page-id", help="Update existing page by ID"),
|
|
509
|
+
chapter_id: int | None = typer.Option(None, "--chapter-id", help="Parent chapter ID"),
|
|
510
|
+
):
|
|
511
|
+
"""Import a markdown file into a BookStack page.
|
|
512
|
+
|
|
513
|
+
Handles local image references:
|
|
514
|
+
- Uploads found images as page attachments
|
|
515
|
+
- Replaces local paths with attachment URLs
|
|
516
|
+
|
|
517
|
+
Provide --book-id to create a new page, or --page-id to update existing.
|
|
518
|
+
"""
|
|
519
|
+
if not book_id and not page_id:
|
|
520
|
+
_print({"error": "Provide --book-id (new page) or --page-id (update existing)"})
|
|
521
|
+
raise typer.Exit(1)
|
|
522
|
+
|
|
523
|
+
page_name = name if name else file.stem
|
|
524
|
+
cfg = get_config()
|
|
525
|
+
|
|
526
|
+
with _client() as c:
|
|
527
|
+
import bookstack_cli.resources.pages as r
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
result = _run(
|
|
531
|
+
r.import_markdown_file(
|
|
532
|
+
c,
|
|
533
|
+
file_path=str(file),
|
|
534
|
+
page_name=page_name,
|
|
535
|
+
book_id=book_id if book_id else 0,
|
|
536
|
+
chapter_id=chapter_id,
|
|
537
|
+
page_id=page_id,
|
|
538
|
+
instance_url=cfg.url,
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
_print(result)
|
|
542
|
+
except Exception as e:
|
|
543
|
+
_print({"error": str(e)})
|
|
544
|
+
raise typer.Exit(1)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@pages_app.command("delete")
|
|
548
|
+
def pages_delete(id: int = typer.Argument(..., help="Page ID")):
|
|
549
|
+
"""Delete a page."""
|
|
550
|
+
with _client() as c:
|
|
551
|
+
import bookstack_cli.resources.pages as r
|
|
552
|
+
|
|
553
|
+
_run(r.delete_page(c, id))
|
|
554
|
+
_print({"ok": True, "id": id})
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@pages_app.command("update")
|
|
558
|
+
def pages_update(
|
|
559
|
+
id: int = typer.Argument(..., help="Page ID"),
|
|
560
|
+
name: str | None = typer.Option(None, "--name", help="New page name"),
|
|
561
|
+
markdown: str | None = typer.Option(None, "--markdown", help="New markdown content"),
|
|
562
|
+
html: str | None = typer.Option(None, "--html", help="New HTML content"),
|
|
563
|
+
markdown_file: Path | None = typer.Option(None, "--markdown-file", help="Read markdown from file"),
|
|
564
|
+
html_file: Path | None = typer.Option(None, "--html-file", help="Read HTML from file"),
|
|
565
|
+
tags: str | None = typer.Option(None, "--tags", help="JSON array of tags"),
|
|
566
|
+
append: str | None = typer.Option(None, "--append", help="Text to append to content"),
|
|
567
|
+
append_file: Path | None = typer.Option(None, "--append-file", help="File to append to content"),
|
|
568
|
+
):
|
|
569
|
+
"""Update a page (partial updates supported — only provided fields change).
|
|
570
|
+
|
|
571
|
+
Content can come from --markdown/--html inline, --markdown-file/--html-file,
|
|
572
|
+
or piped stdin (when no content flags given and stdin is not a TTY).
|
|
573
|
+
|
|
574
|
+
Use --append or --append-file to add content after existing page content
|
|
575
|
+
without reading and rewriting the whole page.
|
|
576
|
+
"""
|
|
577
|
+
import json as _json
|
|
578
|
+
import sys
|
|
579
|
+
|
|
580
|
+
# If appending, fetch current content first
|
|
581
|
+
kwargs: dict[str, Any] = {}
|
|
582
|
+
if name is not None:
|
|
583
|
+
kwargs["name"] = name
|
|
584
|
+
|
|
585
|
+
if append is not None or append_file is not None:
|
|
586
|
+
with _client() as fetch_c:
|
|
587
|
+
current = _run(fetch_c.get(f"pages/{id}"))
|
|
588
|
+
current_md = current.get("markdown", "") or ""
|
|
589
|
+
if append_file:
|
|
590
|
+
append = append_file.read_text()
|
|
591
|
+
kwargs["markdown"] = current_md.rstrip() + "\n\n" + append
|
|
592
|
+
elif markdown_file:
|
|
593
|
+
kwargs["markdown"] = markdown_file.read_text()
|
|
594
|
+
elif html_file:
|
|
595
|
+
kwargs["html"] = html_file.read_text()
|
|
596
|
+
elif markdown is not None:
|
|
597
|
+
kwargs["markdown"] = markdown
|
|
598
|
+
elif html is not None:
|
|
599
|
+
kwargs["html"] = html
|
|
600
|
+
elif not sys.stdin.isatty():
|
|
601
|
+
stdin_content = sys.stdin.read()
|
|
602
|
+
if stdin_content.strip():
|
|
603
|
+
kwargs["markdown"] = stdin_content
|
|
604
|
+
|
|
605
|
+
if tags is not None:
|
|
606
|
+
kwargs["tags"] = _json.loads(tags)
|
|
607
|
+
|
|
608
|
+
if not kwargs:
|
|
609
|
+
_print({"error": "No fields to update. Provide --name, --markdown, --html, --tags, etc."})
|
|
610
|
+
raise typer.Exit(1)
|
|
611
|
+
|
|
612
|
+
with _client() as c:
|
|
613
|
+
import bookstack_cli.resources.pages as r
|
|
614
|
+
from bookstack_cli.models import PageUpdate
|
|
615
|
+
|
|
616
|
+
_print(_run(r.update_page(c, id, PageUpdate(**kwargs))))
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@pages_app.command("resolve-url")
|
|
620
|
+
def pages_resolve_url(
|
|
621
|
+
url: str = typer.Argument(..., help="Full BookStack page URL, e.g. https://wiki/books/my-book/page/my-page"),
|
|
622
|
+
):
|
|
623
|
+
"""Resolve a BookStack web URL to a page ID and content.
|
|
624
|
+
|
|
625
|
+
BookStack web URLs differ from API URLs:
|
|
626
|
+
- Web: https://wiki.example.com/books/my-book/page/my-page
|
|
627
|
+
- API: GET /api/pages/123
|
|
628
|
+
|
|
629
|
+
This command extracts the page slug from the URL and finds the matching page.
|
|
630
|
+
"""
|
|
631
|
+
cfg = get_config()
|
|
632
|
+
with _client() as c:
|
|
633
|
+
import bookstack_cli.resources.pages as r
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
page = _run(r.resolve_page_url(c, url, instance_url=cfg.resolve_url))
|
|
637
|
+
_print(page)
|
|
638
|
+
except ValueError as e:
|
|
639
|
+
_print({"error": str(e)})
|
|
640
|
+
raise typer.Exit(1)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@pages_app.command("export")
|
|
644
|
+
def pages_export(
|
|
645
|
+
id: int = typer.Argument(..., help="Page ID"),
|
|
646
|
+
output_dir: str | None = typer.Option(None, "--output", "-o",
|
|
647
|
+
help="Output directory (default: ./page-{id})"),
|
|
648
|
+
):
|
|
649
|
+
"""Export a page to a local markdown file with downloaded images.
|
|
650
|
+
|
|
651
|
+
Gallery images are downloaded via HTTP. Attachment images are
|
|
652
|
+
downloaded via the API (base64 content field). Image URLs in the
|
|
653
|
+
markdown are replaced with local relative paths.
|
|
654
|
+
"""
|
|
655
|
+
with _client() as c:
|
|
656
|
+
import bookstack_cli.resources.pages as r
|
|
657
|
+
|
|
658
|
+
result = _run(r.export_page(c, id, output_dir=output_dir))
|
|
659
|
+
_print(result)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# ------------------------------------------------------------------
|
|
663
|
+
# Attachment commands
|
|
664
|
+
# ------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@attachments_app.command("list")
|
|
668
|
+
def attachments_list(page_id: int = typer.Option(None, help="Filter by page ID")):
|
|
669
|
+
"""List attachments."""
|
|
670
|
+
with _client() as c:
|
|
671
|
+
import bookstack_cli.resources.attachments as r
|
|
672
|
+
|
|
673
|
+
_print(_collect(r.list_attachments(c, page_id=page_id)))
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@attachments_app.command("get")
|
|
677
|
+
def attachments_get(id: int = typer.Argument(..., help="Attachment ID")):
|
|
678
|
+
"""Get attachment by ID."""
|
|
679
|
+
with _client() as c:
|
|
680
|
+
import bookstack_cli.resources.attachments as r
|
|
681
|
+
|
|
682
|
+
_print(_run(r.get_attachment(c, id)))
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@attachments_app.command("create-link")
|
|
686
|
+
def attachments_create_link(
|
|
687
|
+
name: str = typer.Option(..., prompt=True, help="Attachment name"),
|
|
688
|
+
page_id: int = typer.Option(..., prompt=True, help="Parent page ID"),
|
|
689
|
+
link: str = typer.Option(..., prompt=True, help="URL to link"),
|
|
690
|
+
):
|
|
691
|
+
"""Create a link-type attachment on a page."""
|
|
692
|
+
with _client() as c:
|
|
693
|
+
import bookstack_cli.resources.attachments as r
|
|
694
|
+
from bookstack_cli.models import AttachmentCreate
|
|
695
|
+
|
|
696
|
+
_print(
|
|
697
|
+
_run(
|
|
698
|
+
r.create_attachment_link(
|
|
699
|
+
c, AttachmentCreate(name=name, page_id=page_id, link=link)
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
@attachments_app.command("upload")
|
|
706
|
+
def attachments_upload(
|
|
707
|
+
name: str = typer.Option(..., prompt=True, help="Attachment name"),
|
|
708
|
+
page_id: int = typer.Option(..., prompt=True, help="Parent page ID"),
|
|
709
|
+
file: Path = typer.Option(..., "--file", help="Path to file to upload"),
|
|
710
|
+
):
|
|
711
|
+
"""Upload a file as attachment on a page."""
|
|
712
|
+
file_content = file.read_bytes()
|
|
713
|
+
with _client() as c:
|
|
714
|
+
import bookstack_cli.resources.attachments as r
|
|
715
|
+
|
|
716
|
+
_print(
|
|
717
|
+
_run(
|
|
718
|
+
r.upload_attachment(
|
|
719
|
+
c, name=name, page_id=page_id,
|
|
720
|
+
file_content=file_content, filename=file.name,
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@attachments_app.command("delete")
|
|
727
|
+
def attachments_delete(id: int = typer.Argument(..., help="Attachment ID")):
|
|
728
|
+
"""Delete an attachment."""
|
|
729
|
+
with _client() as c:
|
|
730
|
+
import bookstack_cli.resources.attachments as r
|
|
731
|
+
|
|
732
|
+
_run(r.delete_attachment(c, id))
|
|
733
|
+
_print({"ok": True, "id": id})
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@attachments_app.command("download")
|
|
737
|
+
def attachments_download(
|
|
738
|
+
id: int = typer.Argument(..., help="Attachment ID"),
|
|
739
|
+
output: Path | None = typer.Option(None, "--output", "-o",
|
|
740
|
+
help="Output file path (default: attachment name in CWD)"),
|
|
741
|
+
):
|
|
742
|
+
"""Download an attachment's file content.
|
|
743
|
+
|
|
744
|
+
The BookStack API returns file content as base64 in the attachment response.
|
|
745
|
+
This command decodes it and writes to disk.
|
|
746
|
+
"""
|
|
747
|
+
with _client() as c:
|
|
748
|
+
import bookstack_cli.resources.attachments as r
|
|
749
|
+
|
|
750
|
+
filename, content = _run(r.download_attachment(c, id))
|
|
751
|
+
out_path = output if output else Path(filename)
|
|
752
|
+
out_path.write_bytes(content)
|
|
753
|
+
_print({"ok": True, "attachment_id": id, "file": str(out_path.resolve()), "size": len(content)})
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# ------------------------------------------------------------------
|
|
757
|
+
# User commands
|
|
758
|
+
# ------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
@users_app.command("list")
|
|
762
|
+
def users_list():
|
|
763
|
+
"""List all users (admin)."""
|
|
764
|
+
with _client() as c:
|
|
765
|
+
import bookstack_cli.resources.users as r
|
|
766
|
+
|
|
767
|
+
_print(_collect(r.list_users(c)))
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@users_app.command("get")
|
|
771
|
+
def users_get(id: int = typer.Argument(..., help="User ID")):
|
|
772
|
+
"""Get user by ID."""
|
|
773
|
+
with _client() as c:
|
|
774
|
+
import bookstack_cli.resources.users as r
|
|
775
|
+
|
|
776
|
+
_print(_run(r.get_user(c, id)))
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ------------------------------------------------------------------
|
|
780
|
+
# Role commands
|
|
781
|
+
# ------------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@roles_app.command("list")
|
|
785
|
+
def roles_list():
|
|
786
|
+
"""List all roles (admin)."""
|
|
787
|
+
with _client() as c:
|
|
788
|
+
import bookstack_cli.resources.roles as r
|
|
789
|
+
|
|
790
|
+
_print(_collect(r.list_roles(c)))
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# ------------------------------------------------------------------
|
|
794
|
+
# Search commands
|
|
795
|
+
# ------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@search_app.command("query")
|
|
799
|
+
def search_query(query: str = typer.Argument(..., help="Search term")):
|
|
800
|
+
"""Search across all content."""
|
|
801
|
+
with _client() as c:
|
|
802
|
+
import bookstack_cli.resources.search as r
|
|
803
|
+
|
|
804
|
+
_print(_collect(r.search(c, query=query)))
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ------------------------------------------------------------------
|
|
808
|
+
# Callback
|
|
809
|
+
# ------------------------------------------------------------------
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@app.command("test")
|
|
813
|
+
def config_test():
|
|
814
|
+
"""Test connection to the configured BookStack instance."""
|
|
815
|
+
try:
|
|
816
|
+
cfg = get_config()
|
|
817
|
+
with _client() as c:
|
|
818
|
+
result = _run(c.get("books", params={"count": 1}))
|
|
819
|
+
total = result.get("total", 0)
|
|
820
|
+
_print({
|
|
821
|
+
"ok": True,
|
|
822
|
+
"url": cfg.url,
|
|
823
|
+
"token_id": cfg.token_id,
|
|
824
|
+
"accessible": True,
|
|
825
|
+
"total_books": total,
|
|
826
|
+
})
|
|
827
|
+
except BookStackError as e:
|
|
828
|
+
_print({"ok": False, "error": str(e)})
|
|
829
|
+
raise typer.Exit(1)
|
|
830
|
+
except Exception as e:
|
|
831
|
+
_print({"ok": False, "error": f"Connection failed: {e}"})
|
|
832
|
+
raise typer.Exit(1)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@app.callback()
|
|
836
|
+
def callback() -> None:
|
|
837
|
+
"""BookStack CLI — interact with a BookStack wiki via its REST API.
|
|
838
|
+
|
|
839
|
+
Output is always JSON for easy consumption by coding agents.
|
|
840
|
+
"""
|