confpub-cli 0.5.0__tar.gz → 0.7.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.
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/PKG-INFO +1 -1
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/__init__.py +1 -1
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/applier.py +2 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/cli.py +48 -10
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/confluence.py +22 -7
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/guide.py +43 -4
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/manifest.py +6 -1
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/planner.py +2 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/publish.py +29 -1
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/puller.py +11 -6
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/validator.py +7 -2
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/verifier.py +50 -9
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_confluence.py +1 -1
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_guide.py +1 -1
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_puller.py +7 -1
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_verifier.py +2 -1
- confpub_cli-0.5.0/FEEDBACK.md +0 -226
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/.gitignore +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/LICENSE +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/PRD.md +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/README.md +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/assets.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/config.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/envelope.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/errors.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/lockfile.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/output.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/py.typed +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub.lock +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/pyproject.toml +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/__init__.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/conftest.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_applier.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_assets.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_config.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_envelope.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_errors.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_integration.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_manifest.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_output.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_planner.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_publish.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_validator.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.7.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Agent-first CLI to publish Markdown to Confluence
|
|
5
5
|
Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
|
|
@@ -177,7 +177,7 @@ def page_list(
|
|
|
177
177
|
from confpub.confluence import build_client, _slim_page
|
|
178
178
|
client = build_client()
|
|
179
179
|
pages = client.list_pages(space)
|
|
180
|
-
ctx.result = {"pages": [_slim_page(p) for p in pages]}
|
|
180
|
+
ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/")) for p in pages]}
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
@page_app.command("inspect")
|
|
@@ -186,6 +186,7 @@ def page_inspect(
|
|
|
186
186
|
title: str = typer.Option(None, "--title", help="Page title"),
|
|
187
187
|
page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
188
188
|
raw: bool = typer.Option(False, "--raw", help="Return full raw API response"),
|
|
189
|
+
format: str = typer.Option("storage", "--format", help="Output format: storage (raw HTML) or markdown"),
|
|
189
190
|
) -> None:
|
|
190
191
|
"""Inspect a Confluence page."""
|
|
191
192
|
with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
@@ -201,29 +202,51 @@ def page_inspect(
|
|
|
201
202
|
if not page:
|
|
202
203
|
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
203
204
|
raise ConfpubError(ERR_VALIDATION_NOT_FOUND, f"Page not found")
|
|
204
|
-
|
|
205
|
+
if raw:
|
|
206
|
+
ctx.result = page
|
|
207
|
+
else:
|
|
208
|
+
result = _slim_page(page, base_url=client._config.base_url.rstrip("/"))
|
|
209
|
+
if format == "markdown" and "body_storage" in result:
|
|
210
|
+
from confpub.reverse_converter import convert_storage_to_markdown
|
|
211
|
+
conversion = convert_storage_to_markdown(result["body_storage"])
|
|
212
|
+
result["body_markdown"] = conversion.markdown
|
|
213
|
+
del result["body_storage"]
|
|
214
|
+
if conversion.warnings:
|
|
215
|
+
result["conversion_warnings"] = conversion.warnings
|
|
216
|
+
if conversion.unknown_macros:
|
|
217
|
+
result["unknown_macros"] = conversion.unknown_macros
|
|
218
|
+
ctx.result = result
|
|
205
219
|
|
|
206
220
|
|
|
207
221
|
@page_app.command("publish")
|
|
208
222
|
def page_publish(
|
|
209
223
|
file: str = typer.Argument(..., help="Markdown file to publish"),
|
|
210
224
|
space: str = typer.Option(..., "--space", help="Confluence space key"),
|
|
211
|
-
parent: str = typer.Option(
|
|
225
|
+
parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
|
|
212
226
|
title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
|
|
227
|
+
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
|
|
213
228
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
214
229
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
215
230
|
) -> None:
|
|
216
231
|
"""Publish a single Markdown file to Confluence."""
|
|
217
232
|
from confpub.publish import derive_title
|
|
218
233
|
resolved_title = derive_title(file, title)
|
|
234
|
+
if not page_id and not parent:
|
|
235
|
+
raise ConfpubError(
|
|
236
|
+
"ERR_VALIDATION_REQUIRED",
|
|
237
|
+
"Either --page-id or --parent is required",
|
|
238
|
+
)
|
|
219
239
|
target = {"space": space, "title": resolved_title, "file": file}
|
|
240
|
+
if page_id:
|
|
241
|
+
target["page_id"] = page_id
|
|
220
242
|
with command_context("page.publish", target=target) as ctx:
|
|
221
243
|
from confpub.publish import publish_page
|
|
222
244
|
result = publish_page(
|
|
223
245
|
file=file,
|
|
224
246
|
space=space,
|
|
225
|
-
parent=parent,
|
|
247
|
+
parent=parent or "",
|
|
226
248
|
title=title,
|
|
249
|
+
page_id=page_id,
|
|
227
250
|
dry_run=dry_run,
|
|
228
251
|
backup=backup,
|
|
229
252
|
progress_callback=ctx,
|
|
@@ -270,15 +293,26 @@ def page_pull(
|
|
|
270
293
|
|
|
271
294
|
@page_app.command("delete")
|
|
272
295
|
def page_delete(
|
|
273
|
-
space: str = typer.Option(
|
|
274
|
-
title: str = typer.Option(
|
|
296
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key"),
|
|
297
|
+
title: Optional[str] = typer.Option(None, "--title", help="Page title"),
|
|
298
|
+
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
275
299
|
cascade: bool = typer.Option(False, "--cascade", help="Also delete child pages"),
|
|
276
300
|
) -> None:
|
|
277
301
|
"""Delete a Confluence page."""
|
|
278
|
-
with command_context("page.delete", target={"space": space, "title": title}) as ctx:
|
|
302
|
+
with command_context("page.delete", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
303
|
+
if not page_id and not (space and title):
|
|
304
|
+
raise ConfpubError(
|
|
305
|
+
"ERR_VALIDATION_REQUIRED",
|
|
306
|
+
"Either --page-id or both --space and --title are required",
|
|
307
|
+
)
|
|
279
308
|
from confpub.confluence import build_client
|
|
280
309
|
client = build_client()
|
|
281
|
-
|
|
310
|
+
if page_id:
|
|
311
|
+
if cascade:
|
|
312
|
+
client._delete_descendants(page_id)
|
|
313
|
+
result = client.delete_page(page_id)
|
|
314
|
+
else:
|
|
315
|
+
result = client.delete_page_by_title(space, title, cascade=cascade)
|
|
282
316
|
ctx.result = result
|
|
283
317
|
|
|
284
318
|
|
|
@@ -421,6 +455,7 @@ def config_inspect() -> None:
|
|
|
421
455
|
def search(
|
|
422
456
|
cql: Optional[str] = typer.Option(None, "--cql", help="Raw CQL query"),
|
|
423
457
|
space: Optional[str] = typer.Option(None, "--space", help="Filter by space key"),
|
|
458
|
+
title: Optional[str] = typer.Option(None, "--title", help="Search by page title (fuzzy match)"),
|
|
424
459
|
content_type: Optional[str] = typer.Option(None, "--type", help="Filter by content type (page, blogpost, etc.)"),
|
|
425
460
|
limit: int = typer.Option(25, "--limit", help="Maximum results to return"),
|
|
426
461
|
start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
|
|
@@ -428,12 +463,14 @@ def search(
|
|
|
428
463
|
excerpt_length: int = typer.Option(200, "--excerpt-length", help="Max excerpt chars (0 = unlimited)"),
|
|
429
464
|
) -> None:
|
|
430
465
|
"""Search Confluence content using CQL."""
|
|
431
|
-
target = {"cql": cql, "space": space, "type": content_type}
|
|
466
|
+
target = {"cql": cql, "space": space, "title": title, "type": content_type}
|
|
432
467
|
with command_context("search", target=target) as ctx:
|
|
433
468
|
# Build effective CQL from flags
|
|
434
469
|
fragments: list[str] = []
|
|
435
470
|
if space:
|
|
436
471
|
fragments.append(f'space = "{space}"')
|
|
472
|
+
if title:
|
|
473
|
+
fragments.append(f'title ~ "{title}"')
|
|
437
474
|
if content_type:
|
|
438
475
|
fragments.append(f'type = "{content_type}"')
|
|
439
476
|
if cql:
|
|
@@ -442,7 +479,7 @@ def search(
|
|
|
442
479
|
if not fragments:
|
|
443
480
|
raise ConfpubError(
|
|
444
481
|
"ERR_VALIDATION_REQUIRED",
|
|
445
|
-
"At least one of --cql, --space, or --type is required",
|
|
482
|
+
"At least one of --cql, --space, --title, or --type is required",
|
|
446
483
|
)
|
|
447
484
|
|
|
448
485
|
effective_cql = " AND ".join(fragments)
|
|
@@ -484,6 +521,7 @@ def guide(
|
|
|
484
521
|
raise validation_error(
|
|
485
522
|
ERR_VALIDATION_REQUIRED,
|
|
486
523
|
f"Unknown guide section: {section}",
|
|
524
|
+
valid_sections=list(full_guide.keys()),
|
|
487
525
|
)
|
|
488
526
|
ctx.result = result
|
|
489
527
|
else:
|
|
@@ -32,10 +32,10 @@ class ConfluenceClient:
|
|
|
32
32
|
self._call_count = 0
|
|
33
33
|
|
|
34
34
|
# Suppress noisy atlassian-python-api logging (e.g. "Can't find 'X' page")
|
|
35
|
-
from confpub.output import
|
|
35
|
+
from confpub.output import is_verbose
|
|
36
36
|
|
|
37
37
|
atlassian_logger = logging.getLogger("atlassian")
|
|
38
|
-
atlassian_logger.setLevel(logging.
|
|
38
|
+
atlassian_logger.setLevel(logging.WARNING if is_verbose() else logging.CRITICAL)
|
|
39
39
|
|
|
40
40
|
@staticmethod
|
|
41
41
|
def _build_api(config: ResolvedConfig) -> Any:
|
|
@@ -74,7 +74,7 @@ class ConfluenceClient:
|
|
|
74
74
|
raise ConfpubError(
|
|
75
75
|
ERR_AUTH_FORBIDDEN,
|
|
76
76
|
f"Permission denied ({context}): {msg}",
|
|
77
|
-
|
|
77
|
+
details={"note": "This may indicate a nonexistent resource; Confluence returns 403 for both."},
|
|
78
78
|
) from exc
|
|
79
79
|
# Not found (404 or explicit "not found")
|
|
80
80
|
if "404" in msg or "not found" in msg.lower():
|
|
@@ -93,7 +93,7 @@ class ConfluenceClient:
|
|
|
93
93
|
"""Get a page by space key and title."""
|
|
94
94
|
self._call_count += 1
|
|
95
95
|
try:
|
|
96
|
-
result = self._api.get_page_by_title(space, title, expand="version,body.storage,space")
|
|
96
|
+
result = self._api.get_page_by_title(space, title, expand="version,body.storage,space,ancestors")
|
|
97
97
|
return result if result else None
|
|
98
98
|
except Exception as exc:
|
|
99
99
|
self._handle_error(exc, "get_page")
|
|
@@ -103,7 +103,7 @@ class ConfluenceClient:
|
|
|
103
103
|
"""Get a page by its Confluence ID."""
|
|
104
104
|
self._call_count += 1
|
|
105
105
|
try:
|
|
106
|
-
return self._api.get_page_by_id(page_id, expand="version,body.storage,space")
|
|
106
|
+
return self._api.get_page_by_id(page_id, expand="version,body.storage,space,ancestors")
|
|
107
107
|
except Exception as exc:
|
|
108
108
|
self._handle_error(exc, "get_page_by_id")
|
|
109
109
|
return {}
|
|
@@ -230,6 +230,16 @@ class ConfluenceClient:
|
|
|
230
230
|
start += limit
|
|
231
231
|
return all_children
|
|
232
232
|
|
|
233
|
+
def get_page_ancestors(self, page_id: str) -> list[dict[str, Any]]:
|
|
234
|
+
"""Get the ancestor chain of a page (root first, immediate parent last)."""
|
|
235
|
+
self._call_count += 1
|
|
236
|
+
try:
|
|
237
|
+
page = self._api.get_page_by_id(page_id, expand="ancestors")
|
|
238
|
+
return page.get("ancestors", [])
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
self._handle_error(exc, "get_page_ancestors")
|
|
241
|
+
return []
|
|
242
|
+
|
|
233
243
|
# ------------------------------------------------------------------
|
|
234
244
|
# Space operations
|
|
235
245
|
# ------------------------------------------------------------------
|
|
@@ -403,7 +413,7 @@ class ConfluenceClient:
|
|
|
403
413
|
return None
|
|
404
414
|
|
|
405
415
|
|
|
406
|
-
def _slim_page(page: dict[str, Any]) -> dict[str, Any]:
|
|
416
|
+
def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
|
|
407
417
|
"""Extract agent-relevant fields from a raw Confluence page object."""
|
|
408
418
|
result: dict[str, Any] = {
|
|
409
419
|
"id": page.get("id"),
|
|
@@ -419,9 +429,14 @@ def _slim_page(page: dict[str, Any]) -> dict[str, Any]:
|
|
|
419
429
|
body = page.get("body", {}).get("storage", {}).get("value")
|
|
420
430
|
if body is not None:
|
|
421
431
|
result["body_storage"] = body
|
|
432
|
+
ancestors = page.get("ancestors")
|
|
433
|
+
if isinstance(ancestors, list) and ancestors:
|
|
434
|
+
parent = ancestors[-1]
|
|
435
|
+
result["parent_id"] = parent.get("id")
|
|
436
|
+
result["parent_title"] = parent.get("title")
|
|
422
437
|
links = page.get("_links", {})
|
|
423
438
|
if "webui" in links:
|
|
424
|
-
base = links.get("base", "")
|
|
439
|
+
base = base_url or links.get("base", "")
|
|
425
440
|
result["webui"] = base + links["webui"]
|
|
426
441
|
return result
|
|
427
442
|
|
|
@@ -65,7 +65,8 @@ def build_guide() -> dict[str, Any]:
|
|
|
65
65
|
"group": "read",
|
|
66
66
|
"mutates": False,
|
|
67
67
|
"description": "Search Confluence content using CQL",
|
|
68
|
-
"flags": ["--cql", "--space", "--type", "--limit", "--start", "--include-archived", "--excerpt-length"],
|
|
68
|
+
"flags": ["--cql", "--space", "--title", "--type", "--limit", "--start", "--include-archived", "--excerpt-length"],
|
|
69
|
+
"agent_hint": "Most agent workflows should include --type page to exclude attachments and space entities from results.",
|
|
69
70
|
"result_schema": {
|
|
70
71
|
"cql_query": "string — effective CQL sent to the API",
|
|
71
72
|
"results": "list of {id, type, title, excerpt, url, space_key, entity_type, status, last_modified, container_title}",
|
|
@@ -78,6 +79,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
78
79
|
'confpub search --cql \'label = "api-docs"\'',
|
|
79
80
|
"confpub search --space DEV --type page --limit 10",
|
|
80
81
|
'confpub search --space DEV --cql \'title ~ "deploy"\'',
|
|
82
|
+
'confpub search --title "deploy guide" --space DEV',
|
|
81
83
|
],
|
|
82
84
|
},
|
|
83
85
|
"page.list": {
|
|
@@ -90,13 +92,13 @@ def build_guide() -> dict[str, Any]:
|
|
|
90
92
|
"group": "read",
|
|
91
93
|
"mutates": False,
|
|
92
94
|
"description": "Inspect a Confluence page",
|
|
93
|
-
"flags": ["--space", "--title", "--page-id"],
|
|
95
|
+
"flags": ["--space", "--title", "--page-id", "--format"],
|
|
94
96
|
},
|
|
95
97
|
"page.publish": {
|
|
96
98
|
"group": "write",
|
|
97
99
|
"mutates": True,
|
|
98
100
|
"description": "Publish a single Markdown file to Confluence",
|
|
99
|
-
"flags": ["--space", "--parent", "--title", "--dry-run", "--backup"],
|
|
101
|
+
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
|
|
100
102
|
},
|
|
101
103
|
"page.pull": {
|
|
102
104
|
"group": "read",
|
|
@@ -115,7 +117,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
115
117
|
"group": "write",
|
|
116
118
|
"mutates": True,
|
|
117
119
|
"description": "Delete a Confluence page",
|
|
118
|
-
"flags": ["--space", "--title", "--cascade"],
|
|
120
|
+
"flags": ["--space", "--title", "--page-id", "--cascade"],
|
|
119
121
|
"safety_flags": {
|
|
120
122
|
"--cascade": "Also deletes child pages",
|
|
121
123
|
},
|
|
@@ -232,6 +234,43 @@ def build_guide() -> dict[str, Any]:
|
|
|
232
234
|
"to the same workspace return ERR_CONFLICT_LOCK"
|
|
233
235
|
),
|
|
234
236
|
},
|
|
237
|
+
"lockfile": {
|
|
238
|
+
"description": "Local state file tracking page IDs and versions from publish/pull operations.",
|
|
239
|
+
"file": "confpub.lock",
|
|
240
|
+
"schema": {
|
|
241
|
+
"schema_version": "Lockfile format version (currently '1.0')",
|
|
242
|
+
"last_updated": "ISO 8601 timestamp of last write",
|
|
243
|
+
"pages": "Map of page title to { page_id, version }",
|
|
244
|
+
},
|
|
245
|
+
"behavior": [
|
|
246
|
+
"Created/updated automatically by page.publish, page.pull, and plan.apply",
|
|
247
|
+
"Written atomically (temp file + rename) for crash safety",
|
|
248
|
+
"Used by plan.create to detect existing pages and versions",
|
|
249
|
+
"Does not prevent concurrent operations — purely local state tracking",
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
"assertions": {
|
|
253
|
+
"description": "Post-condition assertions verified by plan.verify.",
|
|
254
|
+
"file_format": "JSON array of assertion objects, or embedded in confpub.yaml under the 'assertions' key.",
|
|
255
|
+
"auto_generation": "When --plan is passed without --assertions, plan.verify auto-generates page.exists assertions for every create/update page in the plan.",
|
|
256
|
+
"types": {
|
|
257
|
+
"page.exists": {
|
|
258
|
+
"description": "Verify that a page exists in the given space.",
|
|
259
|
+
"required_fields": ["type", "space", "title"],
|
|
260
|
+
"example": {"type": "page.exists", "space": "DEV", "title": "My Page"},
|
|
261
|
+
},
|
|
262
|
+
"page.parent": {
|
|
263
|
+
"description": "Verify that a page has the expected parent.",
|
|
264
|
+
"required_fields": ["type", "space", "title", "expected_parent"],
|
|
265
|
+
"example": {"type": "page.parent", "space": "DEV", "title": "My Page", "expected_parent": "Parent Page"},
|
|
266
|
+
},
|
|
267
|
+
"attachment.exists": {
|
|
268
|
+
"description": "Verify that an attachment exists on a page.",
|
|
269
|
+
"required_fields": ["type", "space", "page", "filename"],
|
|
270
|
+
"example": {"type": "attachment.exists", "space": "DEV", "page": "My Page", "filename": "diagram.png"},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
235
274
|
"auth": {
|
|
236
275
|
"precedence": [
|
|
237
276
|
"--token + --user",
|
|
@@ -139,7 +139,12 @@ def load_manifest(path: str) -> Manifest:
|
|
|
139
139
|
p = Path(path)
|
|
140
140
|
if not p.exists():
|
|
141
141
|
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
142
|
-
raise ConfpubError(
|
|
142
|
+
raise ConfpubError(
|
|
143
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
144
|
+
f"Manifest not found: {path}",
|
|
145
|
+
retryable=False,
|
|
146
|
+
suggested_action="fix_input",
|
|
147
|
+
)
|
|
143
148
|
try:
|
|
144
149
|
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
145
150
|
if not isinstance(data, dict):
|
|
@@ -35,6 +35,7 @@ def publish_page(
|
|
|
35
35
|
space: str,
|
|
36
36
|
parent: str,
|
|
37
37
|
title: str | None = None,
|
|
38
|
+
page_id: str | None = None,
|
|
38
39
|
dry_run: bool = False,
|
|
39
40
|
backup: bool = False,
|
|
40
41
|
progress_callback: Any = None,
|
|
@@ -49,6 +50,8 @@ def publish_page(
|
|
|
49
50
|
ERR_IO_FILE_NOT_FOUND,
|
|
50
51
|
f"Source file not found: {file}",
|
|
51
52
|
details={"file": file},
|
|
53
|
+
retryable=False,
|
|
54
|
+
suggested_action="fix_input",
|
|
52
55
|
)
|
|
53
56
|
|
|
54
57
|
# Derive title from filename if not provided
|
|
@@ -71,7 +74,14 @@ def publish_page(
|
|
|
71
74
|
existing_page_id = None
|
|
72
75
|
current_version = None
|
|
73
76
|
|
|
74
|
-
if
|
|
77
|
+
if page_id:
|
|
78
|
+
# Direct page ID provided — skip lockfile/title lookup
|
|
79
|
+
existing_page_id = page_id
|
|
80
|
+
page_data = client.get_page_by_id(existing_page_id)
|
|
81
|
+
if page_data:
|
|
82
|
+
version = page_data.get("version", {})
|
|
83
|
+
current_version = version.get("number") if isinstance(version, dict) else version
|
|
84
|
+
elif page_title in lockfile.pages:
|
|
75
85
|
existing_page_id = lockfile.pages[page_title].page_id
|
|
76
86
|
page_data = client.get_page_by_id(existing_page_id)
|
|
77
87
|
if page_data:
|
|
@@ -97,6 +107,12 @@ def publish_page(
|
|
|
97
107
|
# Determine operation
|
|
98
108
|
operation = "update" if existing_page_id else "create"
|
|
99
109
|
|
|
110
|
+
# Detect noop — skip update when content is unchanged
|
|
111
|
+
if operation == "update":
|
|
112
|
+
remote_fingerprint = client.fingerprint_page(existing_page_id)
|
|
113
|
+
if remote_fingerprint and remote_fingerprint == local_fingerprint:
|
|
114
|
+
operation = "noop"
|
|
115
|
+
|
|
100
116
|
if dry_run:
|
|
101
117
|
change: dict[str, Any] = {
|
|
102
118
|
"type": f"page.{operation}",
|
|
@@ -122,6 +138,18 @@ def publish_page(
|
|
|
122
138
|
return result_dict
|
|
123
139
|
|
|
124
140
|
# Real publish
|
|
141
|
+
if operation == "noop":
|
|
142
|
+
return {
|
|
143
|
+
"dry_run": False,
|
|
144
|
+
"changes": [{
|
|
145
|
+
"type": "page.noop",
|
|
146
|
+
"title": page_title,
|
|
147
|
+
"confluence_page_id": existing_page_id,
|
|
148
|
+
"before": {"version": current_version} if current_version else None,
|
|
149
|
+
}],
|
|
150
|
+
"summary": {"noop": 1},
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
backup_file_path: str | None = None
|
|
126
154
|
if operation == "create":
|
|
127
155
|
# Find parent page
|
|
@@ -107,8 +107,9 @@ def _compute_file_paths(
|
|
|
107
107
|
while current and current != root_page_id:
|
|
108
108
|
chain.append(id_to_slug.get(current, current))
|
|
109
109
|
current = id_to_parent.get(current) # type: ignore[assignment]
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
# Always include root as the outermost directory
|
|
111
|
+
if root_slug:
|
|
112
|
+
chain.append(root_slug)
|
|
112
113
|
chain.reverse()
|
|
113
114
|
rel_path = os.path.join(*chain, "index.md") if len(chain) > 0 else f"{slug}/index.md"
|
|
114
115
|
paths[pid] = os.path.join(output_dir, rel_path)
|
|
@@ -185,6 +186,7 @@ def _build_page_tree(
|
|
|
185
186
|
pages: list[dict[str, Any]],
|
|
186
187
|
file_paths: dict[str, str],
|
|
187
188
|
root_page_id: str,
|
|
189
|
+
output_dir: str = ".",
|
|
188
190
|
) -> list[dict[str, Any]]:
|
|
189
191
|
"""Build a hierarchical page tree for manifest generation."""
|
|
190
192
|
id_to_entry: dict[str, dict[str, Any]] = {}
|
|
@@ -198,7 +200,7 @@ def _build_page_tree(
|
|
|
198
200
|
|
|
199
201
|
id_to_entry[pid] = {
|
|
200
202
|
"title": page.get("title", ""),
|
|
201
|
-
"file": os.path.
|
|
203
|
+
"file": os.path.relpath(file_path, output_dir) if file_path else "",
|
|
202
204
|
"children": [],
|
|
203
205
|
}
|
|
204
206
|
children_map.setdefault(parent_id, []).append(pid)
|
|
@@ -308,10 +310,13 @@ def pull_pages(
|
|
|
308
310
|
|
|
309
311
|
# Generate manifest if requested or recursive with multiple pages
|
|
310
312
|
manifest_file: str | None = None
|
|
311
|
-
if generate_manifest
|
|
313
|
+
if generate_manifest:
|
|
312
314
|
root_title = root_page.get("title", "")
|
|
313
|
-
|
|
314
|
-
|
|
315
|
+
# Determine the actual parent of the root page
|
|
316
|
+
ancestors = client.get_page_ancestors(root_id)
|
|
317
|
+
manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
|
|
318
|
+
page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir)
|
|
319
|
+
manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
|
|
315
320
|
manifest_path = os.path.join(output_dir, "confpub.yaml")
|
|
316
321
|
Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
|
|
317
322
|
manifest_file = manifest_path
|
|
@@ -19,9 +19,14 @@ from confpub.manifest import PlanArtifact, PlanPage
|
|
|
19
19
|
|
|
20
20
|
def _load_plan(plan_path: str) -> PlanArtifact:
|
|
21
21
|
"""Load and parse a plan artifact JSON file."""
|
|
22
|
-
p = Path(plan_path)
|
|
22
|
+
p = Path(plan_path).resolve()
|
|
23
23
|
if not p.exists():
|
|
24
|
-
raise ConfpubError(
|
|
24
|
+
raise ConfpubError(
|
|
25
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
26
|
+
f"Plan file not found: {plan_path}",
|
|
27
|
+
retryable=False,
|
|
28
|
+
suggested_action="fix_input",
|
|
29
|
+
)
|
|
25
30
|
try:
|
|
26
31
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
27
32
|
return PlanArtifact(**data)
|
|
@@ -20,7 +20,12 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
20
20
|
if assertions_path:
|
|
21
21
|
p = Path(assertions_path)
|
|
22
22
|
if not p.exists():
|
|
23
|
-
raise ConfpubError(
|
|
23
|
+
raise ConfpubError(
|
|
24
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
25
|
+
f"Assertions file not found: {assertions_path}",
|
|
26
|
+
retryable=False,
|
|
27
|
+
suggested_action="fix_input",
|
|
28
|
+
)
|
|
24
29
|
try:
|
|
25
30
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
26
31
|
if isinstance(data, list):
|
|
@@ -30,13 +35,41 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
30
35
|
raise ConfpubError(ERR_VALIDATION_MANIFEST, f"Invalid assertions JSON: {exc}") from exc
|
|
31
36
|
|
|
32
37
|
if plan_path:
|
|
33
|
-
# Try to load assertions from the plan's source manifest
|
|
34
38
|
p = Path(plan_path)
|
|
35
39
|
if not p.exists():
|
|
36
|
-
raise ConfpubError(
|
|
40
|
+
raise ConfpubError(
|
|
41
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
42
|
+
f"Plan file not found: {plan_path}",
|
|
43
|
+
retryable=False,
|
|
44
|
+
suggested_action="fix_input",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Check for confpub.yaml alongside the plan file
|
|
48
|
+
manifest_path = p.parent / "confpub.yaml"
|
|
49
|
+
if manifest_path.exists():
|
|
50
|
+
try:
|
|
51
|
+
import yaml
|
|
52
|
+
manifest_data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
|
53
|
+
if isinstance(manifest_data, dict) and manifest_data.get("assertions"):
|
|
54
|
+
raw = manifest_data["assertions"]
|
|
55
|
+
if isinstance(raw, list):
|
|
56
|
+
return raw
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Auto-generate page.exists assertions from the plan
|
|
37
61
|
plan_data = json.loads(p.read_text(encoding="utf-8"))
|
|
38
|
-
|
|
39
|
-
|
|
62
|
+
auto: list[dict[str, Any]] = []
|
|
63
|
+
space = plan_data.get("space", "")
|
|
64
|
+
for page in plan_data.get("pages", []):
|
|
65
|
+
op = page.get("operation", "")
|
|
66
|
+
if op in ("create", "update"):
|
|
67
|
+
auto.append({
|
|
68
|
+
"type": "page.exists",
|
|
69
|
+
"space": space,
|
|
70
|
+
"title": page.get("title", ""),
|
|
71
|
+
})
|
|
72
|
+
return auto
|
|
40
73
|
|
|
41
74
|
return []
|
|
42
75
|
|
|
@@ -78,14 +111,22 @@ def verify_assertions(
|
|
|
78
111
|
all_passed = False
|
|
79
112
|
|
|
80
113
|
elif a_type == "page.parent":
|
|
114
|
+
space = assertion.get("space", "")
|
|
81
115
|
title = assertion.get("title", "")
|
|
82
116
|
expected_parent = assertion.get("expected_parent", "")
|
|
83
117
|
result["title"] = title
|
|
84
|
-
# Get page and check its parent
|
|
85
|
-
# This is a simplified check — in a full implementation
|
|
86
|
-
# we'd look up the page's ancestor chain
|
|
87
|
-
result["passed"] = True # Simplified for now
|
|
88
118
|
result["expected_parent"] = expected_parent
|
|
119
|
+
page = client.get_page(space, title) if space else None
|
|
120
|
+
if page:
|
|
121
|
+
ancestors = client.get_page_ancestors(str(page["id"]))
|
|
122
|
+
actual_parent = ancestors[-1].get("title", "") if ancestors else ""
|
|
123
|
+
result["actual_parent"] = actual_parent
|
|
124
|
+
result["passed"] = actual_parent == expected_parent
|
|
125
|
+
else:
|
|
126
|
+
result["passed"] = False
|
|
127
|
+
result["error"] = f"Page '{title}' not found"
|
|
128
|
+
if not result["passed"]:
|
|
129
|
+
all_passed = False
|
|
89
130
|
|
|
90
131
|
elif a_type == "attachment.exists":
|
|
91
132
|
page_title = assertion.get("page", "")
|
|
@@ -52,7 +52,7 @@ class TestGetPage:
|
|
|
52
52
|
result = client.get_page("DEV", "Test")
|
|
53
53
|
assert result["id"] == "123"
|
|
54
54
|
client._mock_api.get_page_by_title.assert_called_once_with(
|
|
55
|
-
"DEV", "Test", expand="version,body.storage,space"
|
|
55
|
+
"DEV", "Test", expand="version,body.storage,space,ancestors"
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
def test_returns_none_when_not_found(self, client):
|
|
@@ -6,7 +6,7 @@ from confpub.guide import build_guide
|
|
|
6
6
|
class TestBuildGuide:
|
|
7
7
|
def test_has_required_top_level_keys(self):
|
|
8
8
|
guide = build_guide()
|
|
9
|
-
for key in ("schema_version", "commands", "error_codes", "auth", "concurrency"):
|
|
9
|
+
for key in ("schema_version", "commands", "error_codes", "auth", "concurrency", "lockfile"):
|
|
10
10
|
assert key in guide, f"Missing top-level key: {key}"
|
|
11
11
|
|
|
12
12
|
def test_all_commands_present(self):
|
|
@@ -58,11 +58,15 @@ def _mock_client(pages: dict[str, dict], children: dict[str, list] | None = None
|
|
|
58
58
|
def download_attachment(pid, filename, path):
|
|
59
59
|
return False
|
|
60
60
|
|
|
61
|
+
def get_page_ancestors(pid):
|
|
62
|
+
return []
|
|
63
|
+
|
|
61
64
|
client.get_page_by_id = get_page_by_id
|
|
62
65
|
client.get_page = get_page
|
|
63
66
|
client.get_page_children_deep = get_page_children_deep
|
|
64
67
|
client.get_attachments = get_attachments
|
|
65
68
|
client.download_attachment = download_attachment
|
|
69
|
+
client.get_page_ancestors = get_page_ancestors
|
|
66
70
|
return client
|
|
67
71
|
|
|
68
72
|
|
|
@@ -176,6 +180,7 @@ class TestRecursivePull:
|
|
|
176
180
|
page_id="1",
|
|
177
181
|
output_dir=str(tmp_path),
|
|
178
182
|
recursive=True,
|
|
183
|
+
generate_manifest=True,
|
|
179
184
|
)
|
|
180
185
|
|
|
181
186
|
assert result["summary"]["manifest_generated"] is True
|
|
@@ -265,7 +270,7 @@ class TestLayoutModes:
|
|
|
265
270
|
|
|
266
271
|
assert result["layout"] == "nested"
|
|
267
272
|
assert (tmp_path / "root" / "index.md").exists()
|
|
268
|
-
assert (tmp_path / "child" / "index.md").exists()
|
|
273
|
+
assert (tmp_path / "root" / "child" / "index.md").exists()
|
|
269
274
|
|
|
270
275
|
|
|
271
276
|
# ---------------------------------------------------------------------------
|
|
@@ -405,6 +410,7 @@ class TestDataCenterCompat:
|
|
|
405
410
|
result = pull_pages(
|
|
406
411
|
space="PROJ", title="Root",
|
|
407
412
|
output_dir=str(tmp_path), recursive=True,
|
|
413
|
+
generate_manifest=True,
|
|
408
414
|
)
|
|
409
415
|
|
|
410
416
|
assert result["summary"]["pages_pulled"] == 2
|
|
@@ -12,7 +12,7 @@ from confpub.verifier import verify_assertions
|
|
|
12
12
|
|
|
13
13
|
SAMPLE_ASSERTIONS = [
|
|
14
14
|
{"type": "page.exists", "space": "DEV", "title": "Overview"},
|
|
15
|
-
{"type": "page.parent", "title": "Child", "expected_parent": "Overview"},
|
|
15
|
+
{"type": "page.parent", "space": "DEV", "title": "Child", "expected_parent": "Overview"},
|
|
16
16
|
{"type": "attachment.exists", "space": "DEV", "page": "Overview", "filename": "arch.png"},
|
|
17
17
|
]
|
|
18
18
|
|
|
@@ -31,6 +31,7 @@ class TestVerifyAssertions:
|
|
|
31
31
|
mock_client = MagicMock()
|
|
32
32
|
mock_client.get_page.return_value = {"id": "123", "title": "Overview"}
|
|
33
33
|
mock_client.get_attachments.return_value = [{"title": "arch.png"}]
|
|
34
|
+
mock_client.get_page_ancestors.return_value = [{"id": "100", "title": "Overview"}]
|
|
34
35
|
MockClient.return_value = mock_client
|
|
35
36
|
mock_config.return_value = MagicMock()
|
|
36
37
|
|
confpub_cli-0.5.0/FEEDBACK.md
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
# confpub-cli v0.2.3 — Blind Test Feedback
|
|
2
|
-
|
|
3
|
-
**Tester**: Claude Opus 4.6 (LLM agent)
|
|
4
|
-
**Date**: 2026-03-01
|
|
5
|
-
**Environment**: Windows 11, bash shell, `uvx confpub-cli`
|
|
6
|
-
**Confluence**: Cloud instance (thomasklokrohde.atlassian.net)
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## Overall Impression
|
|
11
|
-
|
|
12
|
-
confpub-cli is a remarkably well-designed agent-first CLI. As an LLM agent driving it
|
|
13
|
-
zero-shot, I was productive within seconds. The `guide` command gave me everything I
|
|
14
|
-
needed to understand the full command surface, error taxonomy, and concurrency rules.
|
|
15
|
-
The consistent JSON envelope made it trivial to parse every response. This is one of
|
|
16
|
-
the best-designed CLIs I've encountered for agent consumption.
|
|
17
|
-
|
|
18
|
-
**Rating: 4/5** — Excellent foundation with a handful of rough edges to polish.
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## What Works Well
|
|
23
|
-
|
|
24
|
-
### Structured JSON Envelope
|
|
25
|
-
Every command returns the same `{ ok, command, target, result, warnings, errors, metrics }`
|
|
26
|
-
shape. Parsing is zero-effort. The `request_id` and `metrics.duration_ms` fields are a
|
|
27
|
-
nice touch for tracing and diagnostics.
|
|
28
|
-
|
|
29
|
-
### `guide` Command
|
|
30
|
-
Brilliant bootstrapping mechanism. One call gives an agent: every command with its flags,
|
|
31
|
-
mutability annotations, error codes with exit codes and retry hints, auth precedence,
|
|
32
|
-
and concurrency rules. The `--section` flag is useful for targeted queries. This alone
|
|
33
|
-
puts confpub ahead of most CLI tools for agent integration.
|
|
34
|
-
|
|
35
|
-
### Transactional Plan Workflow
|
|
36
|
-
The plan → validate → apply → verify pipeline is well thought out:
|
|
37
|
-
- `plan create` generates a readable artifact with clear operation types
|
|
38
|
-
- `plan validate` checks for drift before apply
|
|
39
|
-
- `plan apply` supports `--dry-run` for preview
|
|
40
|
-
- The lockfile (`confpub.lock`) enables idempotent re-publishing
|
|
41
|
-
|
|
42
|
-
I tested the full cycle and it worked flawlessly: manifest → plan → validate → dry-run → apply → verify.
|
|
43
|
-
|
|
44
|
-
### Markdown Conversion
|
|
45
|
-
Tested headings, bold/italic, inline code, fenced code blocks (with language), tables,
|
|
46
|
-
admonitions (`[!NOTE]`, `[!WARNING]`, `[!TIP]`), strikethrough, and horizontal rules.
|
|
47
|
-
All converted correctly to Confluence Storage Format. Particularly impressed that
|
|
48
|
-
admonitions map to the proper Confluence Info/Warning/Tip macros.
|
|
49
|
-
|
|
50
|
-
### Error Taxonomy
|
|
51
|
-
Stable error codes (`ERR_*`) with structured details, exit codes, and `suggested_action`
|
|
52
|
-
hints (`fix_input`, `retry`, `reauth`, `escalate`). An agent can branch on these without
|
|
53
|
-
parsing human-readable messages. The `retryable` flag and `retry_after_ms` for I/O errors
|
|
54
|
-
are agent-friendly.
|
|
55
|
-
|
|
56
|
-
### Safety Design
|
|
57
|
-
- Write commands are clearly annotated as `mutates: true` in the guide
|
|
58
|
-
- `--dry-run` is available on both `page publish` and `plan apply`
|
|
59
|
-
- `--cascade` is a separate opt-in for cascading deletes
|
|
60
|
-
- `safety_flags` section in the guide calls out dangerous flags
|
|
61
|
-
|
|
62
|
-
### Auth Resolution
|
|
63
|
-
Clean precedence chain (flags → env vars → config file → keychain) with auto-detection
|
|
64
|
-
of Cloud vs Server from the URL. `auth inspect` gives a quick status check.
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## Bugs Found
|
|
69
|
-
|
|
70
|
-
### BUG-1: `--space` flag ignored during page update (Severity: High)
|
|
71
|
-
|
|
72
|
-
When I published a page to space `SD`, then re-published with `--space NONEXIST`, the
|
|
73
|
-
command succeeded (exit 0) and updated the existing page in the `SD` space. The `--space`
|
|
74
|
-
flag was silently ignored on the update path.
|
|
75
|
-
|
|
76
|
-
**Repro:**
|
|
77
|
-
```bash
|
|
78
|
-
confpub page publish test.md --space SD --parent "Software Development"
|
|
79
|
-
# Creates page in SD, version 1
|
|
80
|
-
|
|
81
|
-
confpub page publish test.md --space NONEXIST --parent "Software Development"
|
|
82
|
-
# Expected: error (space not found or page not found in that space)
|
|
83
|
-
# Actual: ok=true, updated page in SD to version 3
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
**Impact**: An agent could accidentally update a page in the wrong space without any
|
|
87
|
-
warning. The title-based lookup appears to match globally (or against the lockfile)
|
|
88
|
-
rather than scoping to the specified space.
|
|
89
|
-
|
|
90
|
-
### BUG-2: Nonexistent page returns `ok: true` with `result: null` (Severity: Medium)
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
confpub page inspect --space SD --title "nonexistent-page"
|
|
94
|
-
# Returns: ok=true, result=null, errors=[]
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
This should return `ok: false` with an appropriate error (e.g., a new `ERR_NOT_FOUND`
|
|
98
|
-
code). An agent checking `ok` to determine success would incorrectly think the call
|
|
99
|
-
succeeded. Currently the only way to detect "not found" is to check if `result` is null,
|
|
100
|
-
which is an undocumented convention.
|
|
101
|
-
|
|
102
|
-
A `"Can't find ... page"` message also leaks to stderr, suggesting the underlying library
|
|
103
|
-
knows it's not found — the CLI just doesn't surface it as a structured error.
|
|
104
|
-
|
|
105
|
-
### BUG-3: Missing required options return exit code 2, not JSON envelope (Severity: Medium)
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
confpub page list
|
|
109
|
-
# Returns Typer's error format: "Missing option '--space'." with exit code 2
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
This breaks the documented invariant: "stdout is exclusively JSON — one object, no
|
|
113
|
-
preamble, no epilogue." An agent expecting to always parse JSON on stdout will crash.
|
|
114
|
-
Exit code 2 is also undocumented (the error code table only covers 0/10/20/40/50/90).
|
|
115
|
-
|
|
116
|
-
**Suggestion**: Catch Typer's `MissingParameter` and convert it to an
|
|
117
|
-
`ERR_VALIDATION_REQUIRED` envelope with exit code 10.
|
|
118
|
-
|
|
119
|
-
### BUG-4: `--backup` flag produces no observable output (Severity: Low)
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
confpub page publish test.md --space SD --parent "Software Development" --backup
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
The command succeeded but the result JSON contains no mention of a backup being created —
|
|
126
|
-
no backup file path, no `backup: true` field, nothing. Either the backup didn't happen,
|
|
127
|
-
or it happened silently. An agent has no way to confirm.
|
|
128
|
-
|
|
129
|
-
### BUG-5: Nonexistent parent accepted in dry-run (Severity: Low)
|
|
130
|
-
|
|
131
|
-
```bash
|
|
132
|
-
confpub page publish test.md --space SD --parent "Nonexistent Parent" --dry-run
|
|
133
|
-
# Returns: ok=true, type=page.update
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Dry-run should ideally validate that the parent page exists, or at least emit a warning.
|
|
137
|
-
Currently it plans an update to a nonexistent parent, which would fail on real apply.
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
|
|
141
|
-
## Improvement Suggestions
|
|
142
|
-
|
|
143
|
-
### 1. Normalize attachment command output
|
|
144
|
-
|
|
145
|
-
`attachment.list` and `attachment.upload` return raw Confluence API responses with
|
|
146
|
-
internal fields (`_expandable`, ARIs, `base64EncodedAri`, full user profiles). Every
|
|
147
|
-
other command returns a curated result. These should be normalized to the same level
|
|
148
|
-
of curation.
|
|
149
|
-
|
|
150
|
-
**Suggested `attachment.list` shape:**
|
|
151
|
-
```json
|
|
152
|
-
{
|
|
153
|
-
"attachments": [
|
|
154
|
-
{
|
|
155
|
-
"id": "att262400",
|
|
156
|
-
"title": "test-attachment.txt",
|
|
157
|
-
"media_type": "application/binary",
|
|
158
|
-
"file_size": 27,
|
|
159
|
-
"download_url": "/download/attachments/360459/test-attachment.txt?..."
|
|
160
|
-
}
|
|
161
|
-
]
|
|
162
|
-
}
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### 2. Add `--format` flag or page count to `page.list`
|
|
166
|
-
|
|
167
|
-
For spaces with many pages, `page.list` returns everything. Consider:
|
|
168
|
-
- A `--limit` / `--offset` for pagination
|
|
169
|
-
- A `--format compact` option (just titles + IDs)
|
|
170
|
-
- Include total count in the result
|
|
171
|
-
|
|
172
|
-
### 3. Suppress `"Can't find ... page"` stderr noise
|
|
173
|
-
|
|
174
|
-
Multiple commands emit `"Can't find 'X' page on ..."` to stderr. This comes from the
|
|
175
|
-
underlying `atlassian-python-api` library but leaks through even with `--quiet`. Since
|
|
176
|
-
not finding a page is a normal flow (e.g., first publish), this shouldn't appear by
|
|
177
|
-
default — maybe only with `--verbose`.
|
|
178
|
-
|
|
179
|
-
### 4. Add stdin support
|
|
180
|
-
|
|
181
|
-
`echo "# Hello" | confpub page publish - --space SD --parent "Docs" --title "Hello"`
|
|
182
|
-
would be useful for piped workflows and agent-generated content. Currently `-` is treated
|
|
183
|
-
as a literal filename.
|
|
184
|
-
|
|
185
|
-
### 5. `plan verify` with no assertions is a no-op
|
|
186
|
-
|
|
187
|
-
```bash
|
|
188
|
-
confpub plan verify --plan confpub-plan.json
|
|
189
|
-
# Returns: all_passed=true, results=[]
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Without `--assertions`, this always passes vacuously. Consider:
|
|
193
|
-
- Auto-generating basic assertions from the plan (pages exist, correct parent, version incremented)
|
|
194
|
-
- Emitting a warning when no assertions are provided
|
|
195
|
-
|
|
196
|
-
### 6. Consider `--json` flag for Typer-level errors
|
|
197
|
-
|
|
198
|
-
As a bridge until BUG-3 is fully fixed, a `--json` flag could force JSON output even
|
|
199
|
-
for framework-level errors (missing options, unknown commands).
|
|
200
|
-
|
|
201
|
-
### 7. Lockfile includes pages from `page publish` and `plan apply`
|
|
202
|
-
|
|
203
|
-
The lockfile accumulated entries from both `page publish` (single-file mode) and
|
|
204
|
-
`plan apply`. This might be intentional for idempotency, but it could surprise users
|
|
205
|
-
who expected the lockfile to only track manifest-managed pages. Consider documenting
|
|
206
|
-
this behavior or separating the two.
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
|
|
210
|
-
## Summary
|
|
211
|
-
|
|
212
|
-
| Area | Score |
|
|
213
|
-
|------|-------|
|
|
214
|
-
| Agent discoverability (`guide`) | 5/5 |
|
|
215
|
-
| JSON envelope consistency | 4/5 (BUG-3 breaks it for Typer errors) |
|
|
216
|
-
| Error handling | 4/5 (BUG-2 masks not-found) |
|
|
217
|
-
| Markdown conversion | 5/5 |
|
|
218
|
-
| Plan workflow | 5/5 |
|
|
219
|
-
| Write correctness | 3/5 (BUG-1 is a real data integrity risk) |
|
|
220
|
-
| Output curation | 3/5 (attachments leak raw API) |
|
|
221
|
-
| Documentation (README) | 5/5 |
|
|
222
|
-
|
|
223
|
-
confpub is production-ready for the core read + plan + publish workflows. The main
|
|
224
|
-
blocker is BUG-1 (space flag ignored on updates), which could cause silent cross-space
|
|
225
|
-
writes. Fixing that plus normalizing the attachment output would bring this to a
|
|
226
|
-
strong 5/5.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|