bookstack-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bookstack_cli-0.1.0/PKG-INFO +227 -0
- bookstack_cli-0.1.0/README.md +197 -0
- bookstack_cli-0.1.0/bookstack_cli/__init__.py +3 -0
- bookstack_cli-0.1.0/bookstack_cli/client.py +211 -0
- bookstack_cli-0.1.0/bookstack_cli/config.py +131 -0
- bookstack_cli-0.1.0/bookstack_cli/exceptions.py +45 -0
- bookstack_cli-0.1.0/bookstack_cli/main.py +840 -0
- bookstack_cli-0.1.0/bookstack_cli/models.py +276 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/__init__.py +1 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/attachments.py +90 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/books.py +64 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/chapters.py +46 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/pages.py +365 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/revisions.py +30 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/roles.py +33 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/search.py +21 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/shelves.py +55 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/tags.py +15 -0
- bookstack_cli-0.1.0/bookstack_cli/resources/users.py +39 -0
- bookstack_cli-0.1.0/bookstack_cli.egg-info/PKG-INFO +227 -0
- bookstack_cli-0.1.0/bookstack_cli.egg-info/SOURCES.txt +30 -0
- bookstack_cli-0.1.0/bookstack_cli.egg-info/dependency_links.txt +1 -0
- bookstack_cli-0.1.0/bookstack_cli.egg-info/entry_points.txt +2 -0
- bookstack_cli-0.1.0/bookstack_cli.egg-info/requires.txt +11 -0
- bookstack_cli-0.1.0/bookstack_cli.egg-info/top_level.txt +1 -0
- bookstack_cli-0.1.0/pyproject.toml +62 -0
- bookstack_cli-0.1.0/setup.cfg +4 -0
- bookstack_cli-0.1.0/tests/test_client.py +284 -0
- bookstack_cli-0.1.0/tests/test_config.py +235 -0
- bookstack_cli-0.1.0/tests/test_main.py +812 -0
- bookstack_cli-0.1.0/tests/test_models.py +241 -0
- bookstack_cli-0.1.0/tests/test_resources.py +542 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bookstack-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for coding agents to interact with a BookStack wiki via its REST API
|
|
5
|
+
Author: Michael Zehrer
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mzehrer/bookstack-cli
|
|
8
|
+
Project-URL: Source, https://github.com/mzehrer/bookstack-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/mzehrer/bookstack-cli/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/mzehrer/bookstack-cli#readme
|
|
11
|
+
Keywords: bookstack,wiki,cli,api,documentation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Documentation
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: httpx>=0.28
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Requires-Dist: typer>=0.15
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=1.4.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-httpx>=0.36.2; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.15.19; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# bookstack-cli
|
|
32
|
+
|
|
33
|
+
[]()
|
|
34
|
+
[]()
|
|
35
|
+
|
|
36
|
+
CLI for coding agents to interact with a [BookStack](https://www.bookstackapp.com/) wiki via its REST API. All output is JSON — built for LLM pipelines, not humans.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bookstack books list | jq '. | length'
|
|
40
|
+
bookstack pages get 42 | jq '.html[:200]'
|
|
41
|
+
bookstack search query "api docs" | jq '.[] | {name, type, score}'
|
|
42
|
+
bookstack test
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# One-liner (no clone needed)
|
|
49
|
+
uv tool install bookstack-cli # from PyPI, once published
|
|
50
|
+
# or from source:
|
|
51
|
+
# uv tool install git+https://github.com/mzehrer/bookstack-cli.git
|
|
52
|
+
|
|
53
|
+
# Or clone for development
|
|
54
|
+
cd bookstack-cli
|
|
55
|
+
make init # or: uv sync
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Setup
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bookstack auth # interactive — prompts for URL, token, secret
|
|
62
|
+
|
|
63
|
+
# If public web URL differs from API (e.g. behind OAuth proxy):
|
|
64
|
+
bookstack auth --resolve-url https://wiki.public.example.com
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Config File
|
|
68
|
+
|
|
69
|
+
Saved to `~/.config/bookstack-cli/config.toml`:
|
|
70
|
+
|
|
71
|
+
```toml
|
|
72
|
+
[connection]
|
|
73
|
+
url = "http://10.0.0.1:8080" # API endpoint (internal)
|
|
74
|
+
resolve_url = "https://wiki.public.example.com" # public web URL (optional)
|
|
75
|
+
token_id = "<your-token-id>"
|
|
76
|
+
token_secret = "<your-token-secret>"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`resolve_url` is optional — defaults to `url` if not set.
|
|
80
|
+
|
|
81
|
+
### Env Vars (override file)
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
export BOOKSTACK_URL=http://10.0.0.1:8080
|
|
85
|
+
export BOOKSTACK_RESOLVE_URL=https://wiki.example.com
|
|
86
|
+
export BOOKSTACK_TOKEN_ID=<your-token-id>
|
|
87
|
+
export BOOKSTACK_TOKEN_SECRET=<your-token-secret>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Precedence: env vars > config file > error.
|
|
91
|
+
|
|
92
|
+
[See auth docs →](docs/authentication.md)
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
$ bookstack --help
|
|
98
|
+
|
|
99
|
+
╭─ Commands ───────────────────────────────────────╮
|
|
100
|
+
│ auth Save connection credentials. │
|
|
101
|
+
│ config Manage connection config. │
|
|
102
|
+
│ test Test connection to BookStack. │
|
|
103
|
+
│ shelves Manage bookshelves. │
|
|
104
|
+
│ books Manage books. │
|
|
105
|
+
│ chapters Manage chapters. │
|
|
106
|
+
│ pages Manage pages. │
|
|
107
|
+
│ attachments Manage attachments. │
|
|
108
|
+
│ users Manage users (admin). │
|
|
109
|
+
│ roles Manage roles (admin). │
|
|
110
|
+
│ search Search content. │
|
|
111
|
+
╰───────────────────────────────────────────────────╯
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Common Workflows
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Test connection
|
|
118
|
+
bookstack test
|
|
119
|
+
|
|
120
|
+
# List all books
|
|
121
|
+
bookstack books list
|
|
122
|
+
|
|
123
|
+
# Get a specific page
|
|
124
|
+
bookstack pages get 42
|
|
125
|
+
|
|
126
|
+
# Create a page from file
|
|
127
|
+
bookstack pages create "My Page" --book-id 1 --markdown-file content.md
|
|
128
|
+
|
|
129
|
+
# Pipe multi-line content
|
|
130
|
+
cat content.md | bookstack pages create "Piped Page" --book-id 1
|
|
131
|
+
|
|
132
|
+
# Append text to existing page
|
|
133
|
+
bookstack pages update 42 --append "New section at the end"
|
|
134
|
+
|
|
135
|
+
# Resolve web URL to page
|
|
136
|
+
bookstack pages resolve-url "https://wiki/books/my-book/page/my-page"
|
|
137
|
+
|
|
138
|
+
# Import markdown with images
|
|
139
|
+
bookstack pages import --file article.md --book-id 1 --name "Article"
|
|
140
|
+
|
|
141
|
+
# Search across all content
|
|
142
|
+
bookstack search query "installation guide"
|
|
143
|
+
|
|
144
|
+
# List attachments on a page
|
|
145
|
+
bookstack attachments list --page-id 10
|
|
146
|
+
|
|
147
|
+
# Upload a file attachment
|
|
148
|
+
bookstack attachments upload --name "Report" --page-id 42 --file report.pdf
|
|
149
|
+
|
|
150
|
+
# Create a shelf and assign books
|
|
151
|
+
bookstack shelves create "Dev Docs"
|
|
152
|
+
bookstack shelves update 1 "Dev Docs" --books "10,20,30"
|
|
153
|
+
|
|
154
|
+
# Update entity
|
|
155
|
+
bookstack books update 1 "New Title"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Features
|
|
159
|
+
|
|
160
|
+
| Feature | Status |
|
|
161
|
+
|---|---|
|
|
162
|
+
| Shelves CRUD (+ book assignment) | ✅ |
|
|
163
|
+
| Books CRUD | ✅ |
|
|
164
|
+
| Chapters CRUD | ✅ |
|
|
165
|
+
| Pages CRUD (partial update, append, move) | ✅ |
|
|
166
|
+
| Markdown import with image handling | ✅ |
|
|
167
|
+
| Web URL → API ID resolution | ✅ |
|
|
168
|
+
| Attachments (link + file upload) | ✅ |
|
|
169
|
+
| Search across content | ✅ |
|
|
170
|
+
| Users/Roles (admin) | ✅ |
|
|
171
|
+
| Async HTTP with retry/backoff | ✅ |
|
|
172
|
+
| Auto-pagination (client-side filtering) | ✅ |
|
|
173
|
+
| Config test / connection check | ✅ |
|
|
174
|
+
| JSON-only output | ✅ |
|
|
175
|
+
|
|
176
|
+
## Project Layout
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
bookstack-cli/
|
|
180
|
+
├── bookstack_cli/
|
|
181
|
+
│ ├── client.py # HTTP client, auth, rate-limit, pagination
|
|
182
|
+
│ ├── config.py # Env vars → ~/.config/bookstack-cli/config.toml
|
|
183
|
+
│ ├── exceptions.py # Typed error hierarchy
|
|
184
|
+
│ ├── models.py # Pydantic models for all entities
|
|
185
|
+
│ ├── main.py # Typer CLI entry point
|
|
186
|
+
│ └── resources/ # One module per entity
|
|
187
|
+
│ ├── books.py
|
|
188
|
+
│ ├── chapters.py
|
|
189
|
+
│ ├── pages.py
|
|
190
|
+
│ ├── shelves.py
|
|
191
|
+
│ ├── attachments.py
|
|
192
|
+
│ ├── search.py
|
|
193
|
+
│ ├── users.py
|
|
194
|
+
│ ├── roles.py
|
|
195
|
+
│ ├── revisions.py
|
|
196
|
+
│ └── tags.py
|
|
197
|
+
├── tests/ # 130+ tests
|
|
198
|
+
├── docs/ # Detailed docs
|
|
199
|
+
├── skill/ # Pi agent skill
|
|
200
|
+
├── Makefile # init/test/lint/format/run
|
|
201
|
+
└── pyproject.toml
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Documentation
|
|
205
|
+
|
|
206
|
+
| File | What |
|
|
207
|
+
|---|---|
|
|
208
|
+
| [docs/overview.md](docs/overview.md) | Architecture, goals, scope |
|
|
209
|
+
| [docs/authentication.md](docs/authentication.md) | Token setup, env config, security |
|
|
210
|
+
| [docs/api-reference.md](docs/api-reference.md) | All endpoints, schemas, pagination |
|
|
211
|
+
| [docs/integration-guide.md](docs/integration-guide.md) | Hacking BookStack, injections, webhooks |
|
|
212
|
+
| [docs/research.md](docs/research.md) | Raw API research findings |
|
|
213
|
+
| [skill/SKILL.md](skill/SKILL.md) | Agent skill for pi/coding agents |
|
|
214
|
+
| [AGENT.md](AGENT.md) | TDD protocol for this project |
|
|
215
|
+
|
|
216
|
+
## Design
|
|
217
|
+
|
|
218
|
+
- **Async from day one** — `httpx.AsyncClient` with retry + exponential backoff on 429s
|
|
219
|
+
- **Pydantic v2** — typed models for every entity, validated responses
|
|
220
|
+
- **Agent-friendly output** — everything is JSON via stdout, no interactive prompts
|
|
221
|
+
- **Resource-per-file** — one module per entity, consistent `list/get/create/update/delete` signatures
|
|
222
|
+
- **Config cascade** — env vars > `~/.config/bookstack-cli/config.toml` > error
|
|
223
|
+
- **TDD** — 130 tests, red/green/refactor cycle (see [AGENT.md](AGENT.md))
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# bookstack-cli
|
|
2
|
+
|
|
3
|
+
[]()
|
|
4
|
+
[]()
|
|
5
|
+
|
|
6
|
+
CLI for coding agents to interact with a [BookStack](https://www.bookstackapp.com/) wiki via its REST API. All output is JSON — built for LLM pipelines, not humans.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bookstack books list | jq '. | length'
|
|
10
|
+
bookstack pages get 42 | jq '.html[:200]'
|
|
11
|
+
bookstack search query "api docs" | jq '.[] | {name, type, score}'
|
|
12
|
+
bookstack test
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# One-liner (no clone needed)
|
|
19
|
+
uv tool install bookstack-cli # from PyPI, once published
|
|
20
|
+
# or from source:
|
|
21
|
+
# uv tool install git+https://github.com/mzehrer/bookstack-cli.git
|
|
22
|
+
|
|
23
|
+
# Or clone for development
|
|
24
|
+
cd bookstack-cli
|
|
25
|
+
make init # or: uv sync
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bookstack auth # interactive — prompts for URL, token, secret
|
|
32
|
+
|
|
33
|
+
# If public web URL differs from API (e.g. behind OAuth proxy):
|
|
34
|
+
bookstack auth --resolve-url https://wiki.public.example.com
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Config File
|
|
38
|
+
|
|
39
|
+
Saved to `~/.config/bookstack-cli/config.toml`:
|
|
40
|
+
|
|
41
|
+
```toml
|
|
42
|
+
[connection]
|
|
43
|
+
url = "http://10.0.0.1:8080" # API endpoint (internal)
|
|
44
|
+
resolve_url = "https://wiki.public.example.com" # public web URL (optional)
|
|
45
|
+
token_id = "<your-token-id>"
|
|
46
|
+
token_secret = "<your-token-secret>"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`resolve_url` is optional — defaults to `url` if not set.
|
|
50
|
+
|
|
51
|
+
### Env Vars (override file)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export BOOKSTACK_URL=http://10.0.0.1:8080
|
|
55
|
+
export BOOKSTACK_RESOLVE_URL=https://wiki.example.com
|
|
56
|
+
export BOOKSTACK_TOKEN_ID=<your-token-id>
|
|
57
|
+
export BOOKSTACK_TOKEN_SECRET=<your-token-secret>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Precedence: env vars > config file > error.
|
|
61
|
+
|
|
62
|
+
[See auth docs →](docs/authentication.md)
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
$ bookstack --help
|
|
68
|
+
|
|
69
|
+
╭─ Commands ───────────────────────────────────────╮
|
|
70
|
+
│ auth Save connection credentials. │
|
|
71
|
+
│ config Manage connection config. │
|
|
72
|
+
│ test Test connection to BookStack. │
|
|
73
|
+
│ shelves Manage bookshelves. │
|
|
74
|
+
│ books Manage books. │
|
|
75
|
+
│ chapters Manage chapters. │
|
|
76
|
+
│ pages Manage pages. │
|
|
77
|
+
│ attachments Manage attachments. │
|
|
78
|
+
│ users Manage users (admin). │
|
|
79
|
+
│ roles Manage roles (admin). │
|
|
80
|
+
│ search Search content. │
|
|
81
|
+
╰───────────────────────────────────────────────────╯
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Common Workflows
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Test connection
|
|
88
|
+
bookstack test
|
|
89
|
+
|
|
90
|
+
# List all books
|
|
91
|
+
bookstack books list
|
|
92
|
+
|
|
93
|
+
# Get a specific page
|
|
94
|
+
bookstack pages get 42
|
|
95
|
+
|
|
96
|
+
# Create a page from file
|
|
97
|
+
bookstack pages create "My Page" --book-id 1 --markdown-file content.md
|
|
98
|
+
|
|
99
|
+
# Pipe multi-line content
|
|
100
|
+
cat content.md | bookstack pages create "Piped Page" --book-id 1
|
|
101
|
+
|
|
102
|
+
# Append text to existing page
|
|
103
|
+
bookstack pages update 42 --append "New section at the end"
|
|
104
|
+
|
|
105
|
+
# Resolve web URL to page
|
|
106
|
+
bookstack pages resolve-url "https://wiki/books/my-book/page/my-page"
|
|
107
|
+
|
|
108
|
+
# Import markdown with images
|
|
109
|
+
bookstack pages import --file article.md --book-id 1 --name "Article"
|
|
110
|
+
|
|
111
|
+
# Search across all content
|
|
112
|
+
bookstack search query "installation guide"
|
|
113
|
+
|
|
114
|
+
# List attachments on a page
|
|
115
|
+
bookstack attachments list --page-id 10
|
|
116
|
+
|
|
117
|
+
# Upload a file attachment
|
|
118
|
+
bookstack attachments upload --name "Report" --page-id 42 --file report.pdf
|
|
119
|
+
|
|
120
|
+
# Create a shelf and assign books
|
|
121
|
+
bookstack shelves create "Dev Docs"
|
|
122
|
+
bookstack shelves update 1 "Dev Docs" --books "10,20,30"
|
|
123
|
+
|
|
124
|
+
# Update entity
|
|
125
|
+
bookstack books update 1 "New Title"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Features
|
|
129
|
+
|
|
130
|
+
| Feature | Status |
|
|
131
|
+
|---|---|
|
|
132
|
+
| Shelves CRUD (+ book assignment) | ✅ |
|
|
133
|
+
| Books CRUD | ✅ |
|
|
134
|
+
| Chapters CRUD | ✅ |
|
|
135
|
+
| Pages CRUD (partial update, append, move) | ✅ |
|
|
136
|
+
| Markdown import with image handling | ✅ |
|
|
137
|
+
| Web URL → API ID resolution | ✅ |
|
|
138
|
+
| Attachments (link + file upload) | ✅ |
|
|
139
|
+
| Search across content | ✅ |
|
|
140
|
+
| Users/Roles (admin) | ✅ |
|
|
141
|
+
| Async HTTP with retry/backoff | ✅ |
|
|
142
|
+
| Auto-pagination (client-side filtering) | ✅ |
|
|
143
|
+
| Config test / connection check | ✅ |
|
|
144
|
+
| JSON-only output | ✅ |
|
|
145
|
+
|
|
146
|
+
## Project Layout
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
bookstack-cli/
|
|
150
|
+
├── bookstack_cli/
|
|
151
|
+
│ ├── client.py # HTTP client, auth, rate-limit, pagination
|
|
152
|
+
│ ├── config.py # Env vars → ~/.config/bookstack-cli/config.toml
|
|
153
|
+
│ ├── exceptions.py # Typed error hierarchy
|
|
154
|
+
│ ├── models.py # Pydantic models for all entities
|
|
155
|
+
│ ├── main.py # Typer CLI entry point
|
|
156
|
+
│ └── resources/ # One module per entity
|
|
157
|
+
│ ├── books.py
|
|
158
|
+
│ ├── chapters.py
|
|
159
|
+
│ ├── pages.py
|
|
160
|
+
│ ├── shelves.py
|
|
161
|
+
│ ├── attachments.py
|
|
162
|
+
│ ├── search.py
|
|
163
|
+
│ ├── users.py
|
|
164
|
+
│ ├── roles.py
|
|
165
|
+
│ ├── revisions.py
|
|
166
|
+
│ └── tags.py
|
|
167
|
+
├── tests/ # 130+ tests
|
|
168
|
+
├── docs/ # Detailed docs
|
|
169
|
+
├── skill/ # Pi agent skill
|
|
170
|
+
├── Makefile # init/test/lint/format/run
|
|
171
|
+
└── pyproject.toml
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Documentation
|
|
175
|
+
|
|
176
|
+
| File | What |
|
|
177
|
+
|---|---|
|
|
178
|
+
| [docs/overview.md](docs/overview.md) | Architecture, goals, scope |
|
|
179
|
+
| [docs/authentication.md](docs/authentication.md) | Token setup, env config, security |
|
|
180
|
+
| [docs/api-reference.md](docs/api-reference.md) | All endpoints, schemas, pagination |
|
|
181
|
+
| [docs/integration-guide.md](docs/integration-guide.md) | Hacking BookStack, injections, webhooks |
|
|
182
|
+
| [docs/research.md](docs/research.md) | Raw API research findings |
|
|
183
|
+
| [skill/SKILL.md](skill/SKILL.md) | Agent skill for pi/coding agents |
|
|
184
|
+
| [AGENT.md](AGENT.md) | TDD protocol for this project |
|
|
185
|
+
|
|
186
|
+
## Design
|
|
187
|
+
|
|
188
|
+
- **Async from day one** — `httpx.AsyncClient` with retry + exponential backoff on 429s
|
|
189
|
+
- **Pydantic v2** — typed models for every entity, validated responses
|
|
190
|
+
- **Agent-friendly output** — everything is JSON via stdout, no interactive prompts
|
|
191
|
+
- **Resource-per-file** — one module per entity, consistent `list/get/create/update/delete` signatures
|
|
192
|
+
- **Config cascade** — env vars > `~/.config/bookstack-cli/config.toml` > error
|
|
193
|
+
- **TDD** — 130 tests, red/green/refactor cycle (see [AGENT.md](AGENT.md))
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Async HTTP client for BookStack API with auth, retry, pagination."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from bookstack_cli.config import get_config
|
|
11
|
+
from bookstack_cli.exceptions import (
|
|
12
|
+
BookStackRateLimitError,
|
|
13
|
+
map_status_to_error,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
BASE_DELAY = 1.0
|
|
19
|
+
MAX_RETRIES = 5
|
|
20
|
+
MAX_PAGE_SIZE = 500
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BookStackClient:
|
|
24
|
+
"""Async HTTP client for BookStack REST API.
|
|
25
|
+
|
|
26
|
+
Handles:
|
|
27
|
+
- Auth header injection (Token token_id:token_secret)
|
|
28
|
+
- Rate-limit retry with exponential backoff
|
|
29
|
+
- Auto-pagination via async generator
|
|
30
|
+
- Error mapping to typed exceptions
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str | None = None,
|
|
36
|
+
token_id: str | None = None,
|
|
37
|
+
token_secret: str | None = None,
|
|
38
|
+
timeout: float = 30.0,
|
|
39
|
+
) -> None:
|
|
40
|
+
if base_url and token_id and token_secret:
|
|
41
|
+
self._base_url = base_url.rstrip("/")
|
|
42
|
+
self._token_id = token_id
|
|
43
|
+
self._token_secret = token_secret
|
|
44
|
+
else:
|
|
45
|
+
cfg = get_config()
|
|
46
|
+
self._base_url = cfg.url
|
|
47
|
+
self._token_id = cfg.token_id
|
|
48
|
+
self._token_secret = cfg.token_secret
|
|
49
|
+
|
|
50
|
+
auth_header_value = f"Token {self._token_id}:{self._token_secret}"
|
|
51
|
+
|
|
52
|
+
self._client = httpx.AsyncClient(
|
|
53
|
+
base_url=self._base_url,
|
|
54
|
+
headers={
|
|
55
|
+
"Authorization": auth_header_value,
|
|
56
|
+
"Accept": "application/json",
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
},
|
|
59
|
+
timeout=httpx.Timeout(timeout),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
"""Close the underlying HTTP client."""
|
|
64
|
+
await self._client.aclose()
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> "BookStackClient":
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
70
|
+
await self.close()
|
|
71
|
+
|
|
72
|
+
def __enter__(self) -> "BookStackClient":
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(self, *args: Any) -> None:
|
|
76
|
+
import asyncio
|
|
77
|
+
try:
|
|
78
|
+
loop = asyncio.get_event_loop()
|
|
79
|
+
except RuntimeError:
|
|
80
|
+
loop = asyncio.new_event_loop()
|
|
81
|
+
asyncio.set_event_loop(loop)
|
|
82
|
+
loop.run_until_complete(self.close())
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Request with retry
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
async def _request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
path: str,
|
|
92
|
+
retry_count: int = 0,
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> httpx.Response:
|
|
95
|
+
"""Send HTTP request with rate-limit retry."""
|
|
96
|
+
url = f"/api/{path.lstrip('/')}"
|
|
97
|
+
# Remove Content-Type for multipart (httpx sets correct boundary)
|
|
98
|
+
has_files = "files" in kwargs
|
|
99
|
+
if has_files:
|
|
100
|
+
old_ct = self._client.headers.pop("Content-Type", None)
|
|
101
|
+
try:
|
|
102
|
+
response = await self._client.request(method, url, **kwargs)
|
|
103
|
+
finally:
|
|
104
|
+
if has_files and old_ct is not None:
|
|
105
|
+
self._client.headers["Content-Type"] = old_ct
|
|
106
|
+
|
|
107
|
+
if response.status_code == 429 and retry_count < MAX_RETRIES:
|
|
108
|
+
retry_after = _parse_retry_after(response)
|
|
109
|
+
delay = max(retry_after, BASE_DELAY * (2**retry_count))
|
|
110
|
+
logger.warning(
|
|
111
|
+
"Rate limited. Retry %d/%d after %.1fs",
|
|
112
|
+
retry_count + 1,
|
|
113
|
+
MAX_RETRIES,
|
|
114
|
+
delay,
|
|
115
|
+
)
|
|
116
|
+
await asyncio.sleep(delay)
|
|
117
|
+
return await self._request(method, path, retry_count + 1, **kwargs)
|
|
118
|
+
|
|
119
|
+
if response.is_error:
|
|
120
|
+
_raise_for_status(response)
|
|
121
|
+
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# CRUD helpers
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
129
|
+
"""GET request returning parsed JSON."""
|
|
130
|
+
response = await self._request("GET", path, params=params)
|
|
131
|
+
return response.json()
|
|
132
|
+
|
|
133
|
+
async def get_raw(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
|
|
134
|
+
"""GET request returning raw response (e.g. for binary downloads)."""
|
|
135
|
+
return await self._request("GET", path, params=params)
|
|
136
|
+
|
|
137
|
+
async def post(
|
|
138
|
+
self, path: str, json: dict[str, Any] | None = None, data: dict[str, Any] | None = None
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""POST request returning parsed JSON."""
|
|
141
|
+
response = await self._request("POST", path, json=json, data=data)
|
|
142
|
+
return response.json()
|
|
143
|
+
|
|
144
|
+
async def put(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
145
|
+
"""PUT request returning parsed JSON."""
|
|
146
|
+
response = await self._request("PUT", path, json=json)
|
|
147
|
+
return response.json()
|
|
148
|
+
|
|
149
|
+
async def delete(self, path: str) -> None:
|
|
150
|
+
"""DELETE request."""
|
|
151
|
+
await self._request("DELETE", path)
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Pagination
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
async def paginate(
|
|
158
|
+
self,
|
|
159
|
+
path: str,
|
|
160
|
+
params: dict[str, Any] | None = None,
|
|
161
|
+
page_size: int = 100,
|
|
162
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
163
|
+
"""Iterate over all pages of a list endpoint.
|
|
164
|
+
|
|
165
|
+
Yields individual items from ``data`` across all pages.
|
|
166
|
+
"""
|
|
167
|
+
params = dict(params or {})
|
|
168
|
+
params.setdefault("count", min(page_size, MAX_PAGE_SIZE))
|
|
169
|
+
page = 1
|
|
170
|
+
|
|
171
|
+
while True:
|
|
172
|
+
params["page"] = page
|
|
173
|
+
data = await self.get(path, params=params)
|
|
174
|
+
items: list[dict[str, Any]] = data.get("data", [])
|
|
175
|
+
for item in items:
|
|
176
|
+
yield item
|
|
177
|
+
|
|
178
|
+
total: int = data.get("total", 0)
|
|
179
|
+
per_page = data.get("per_page")
|
|
180
|
+
if per_page is None:
|
|
181
|
+
per_page = len(items) or page_size
|
|
182
|
+
if page * per_page >= total:
|
|
183
|
+
break
|
|
184
|
+
page += 1
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_retry_after(response: httpx.Response) -> float:
|
|
188
|
+
"""Extract Retry-After header value as float."""
|
|
189
|
+
val = response.headers.get("Retry-After", "1")
|
|
190
|
+
try:
|
|
191
|
+
return float(val)
|
|
192
|
+
except ValueError:
|
|
193
|
+
return 1.0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
197
|
+
"""Map HTTP status to typed BookStack exception."""
|
|
198
|
+
try:
|
|
199
|
+
body = response.json()
|
|
200
|
+
error = body.get("error", {})
|
|
201
|
+
message = str(error.get("message", response.reason_phrase))
|
|
202
|
+
validation = error.get("validation")
|
|
203
|
+
if validation:
|
|
204
|
+
details = "; ".join(
|
|
205
|
+
f"{k}: {', '.join(v)}" for k, v in validation.items()
|
|
206
|
+
)
|
|
207
|
+
message = f"{message} ({details})"
|
|
208
|
+
except Exception:
|
|
209
|
+
message = response.reason_phrase or "Unknown error"
|
|
210
|
+
|
|
211
|
+
raise map_status_to_error(response.status_code, message)
|