confpub-cli 0.6.0__tar.gz → 0.8.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.6.0 → confpub_cli-0.8.0}/PKG-INFO +1 -1
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/__init__.py +1 -1
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/cli.py +35 -8
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/confluence.py +40 -17
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/guide.py +26 -3
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/lockfile.py +1 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/publish.py +37 -2
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/puller.py +6 -3
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/verifier.py +38 -7
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_confluence.py +19 -15
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_puller.py +4 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_verifier.py +2 -1
- confpub_cli-0.6.0/FEEDBACK.md +0 -197
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/.gitignore +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/LICENSE +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/PRD.md +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/README.md +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/applier.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/assets.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/config.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/envelope.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/errors.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/manifest.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/output.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/planner.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/py.typed +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/validator.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub.lock +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/pyproject.toml +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/__init__.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/conftest.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_applier.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_assets.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_config.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_envelope.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_errors.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_guide.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_integration.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_manifest.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_output.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_planner.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_publish.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_validator.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.8.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.8.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
|
|
@@ -222,8 +222,9 @@ def page_inspect(
|
|
|
222
222
|
def page_publish(
|
|
223
223
|
file: str = typer.Argument(..., help="Markdown file to publish"),
|
|
224
224
|
space: str = typer.Option(..., "--space", help="Confluence space key"),
|
|
225
|
-
parent: str = typer.Option(
|
|
225
|
+
parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
|
|
226
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)"),
|
|
227
228
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
228
229
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
229
230
|
) -> None:
|
|
@@ -231,13 +232,21 @@ def page_publish(
|
|
|
231
232
|
from confpub.publish import derive_title
|
|
232
233
|
resolved_title = derive_title(file, title)
|
|
233
234
|
target = {"space": space, "title": resolved_title, "file": file}
|
|
235
|
+
if page_id:
|
|
236
|
+
target["page_id"] = page_id
|
|
234
237
|
with command_context("page.publish", target=target) as ctx:
|
|
238
|
+
if not page_id and not parent:
|
|
239
|
+
raise ConfpubError(
|
|
240
|
+
"ERR_VALIDATION_REQUIRED",
|
|
241
|
+
"Either --page-id or --parent is required",
|
|
242
|
+
)
|
|
235
243
|
from confpub.publish import publish_page
|
|
236
244
|
result = publish_page(
|
|
237
245
|
file=file,
|
|
238
246
|
space=space,
|
|
239
|
-
parent=parent,
|
|
247
|
+
parent=parent or "",
|
|
240
248
|
title=title,
|
|
249
|
+
page_id=page_id,
|
|
241
250
|
dry_run=dry_run,
|
|
242
251
|
backup=backup,
|
|
243
252
|
progress_callback=ctx,
|
|
@@ -284,15 +293,26 @@ def page_pull(
|
|
|
284
293
|
|
|
285
294
|
@page_app.command("delete")
|
|
286
295
|
def page_delete(
|
|
287
|
-
space: str = typer.Option(
|
|
288
|
-
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"),
|
|
289
299
|
cascade: bool = typer.Option(False, "--cascade", help="Also delete child pages"),
|
|
290
300
|
) -> None:
|
|
291
301
|
"""Delete a Confluence page."""
|
|
292
|
-
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
|
+
)
|
|
293
308
|
from confpub.confluence import build_client
|
|
294
309
|
client = build_client()
|
|
295
|
-
|
|
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)
|
|
296
316
|
ctx.result = result
|
|
297
317
|
|
|
298
318
|
|
|
@@ -435,6 +455,7 @@ def config_inspect() -> None:
|
|
|
435
455
|
def search(
|
|
436
456
|
cql: Optional[str] = typer.Option(None, "--cql", help="Raw CQL query"),
|
|
437
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)"),
|
|
438
459
|
content_type: Optional[str] = typer.Option(None, "--type", help="Filter by content type (page, blogpost, etc.)"),
|
|
439
460
|
limit: int = typer.Option(25, "--limit", help="Maximum results to return"),
|
|
440
461
|
start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
|
|
@@ -442,12 +463,14 @@ def search(
|
|
|
442
463
|
excerpt_length: int = typer.Option(200, "--excerpt-length", help="Max excerpt chars (0 = unlimited)"),
|
|
443
464
|
) -> None:
|
|
444
465
|
"""Search Confluence content using CQL."""
|
|
445
|
-
target = {"cql": cql, "space": space, "type": content_type}
|
|
466
|
+
target = {"cql": cql, "space": space, "title": title, "type": content_type}
|
|
446
467
|
with command_context("search", target=target) as ctx:
|
|
447
468
|
# Build effective CQL from flags
|
|
448
469
|
fragments: list[str] = []
|
|
449
470
|
if space:
|
|
450
471
|
fragments.append(f'space = "{space}"')
|
|
472
|
+
if title:
|
|
473
|
+
fragments.append(f'title ~ "{title}"')
|
|
451
474
|
if content_type:
|
|
452
475
|
fragments.append(f'type = "{content_type}"')
|
|
453
476
|
if cql:
|
|
@@ -456,7 +479,7 @@ def search(
|
|
|
456
479
|
if not fragments:
|
|
457
480
|
raise ConfpubError(
|
|
458
481
|
"ERR_VALIDATION_REQUIRED",
|
|
459
|
-
"At least one of --cql, --space, or --type is required",
|
|
482
|
+
"At least one of --cql, --space, --title, or --type is required",
|
|
460
483
|
)
|
|
461
484
|
|
|
462
485
|
effective_cql = " AND ".join(fragments)
|
|
@@ -523,3 +546,7 @@ def run() -> None:
|
|
|
523
546
|
envelope = Envelope.failure("cli", [err])
|
|
524
547
|
emit_stdout(envelope.to_json_bytes())
|
|
525
548
|
sys.exit(10)
|
|
549
|
+
except ConfpubError as e:
|
|
550
|
+
envelope = Envelope.failure("cli", [e])
|
|
551
|
+
emit_stdout(envelope.to_json_bytes())
|
|
552
|
+
sys.exit(exit_code_for(e.code))
|
|
@@ -64,6 +64,7 @@ class ConfluenceClient:
|
|
|
64
64
|
ERR_AUTH_FORBIDDEN,
|
|
65
65
|
f"Permission denied: {msg}",
|
|
66
66
|
suggested_action="escalate",
|
|
67
|
+
details={"note": "Confluence returns HTTP 403 for both forbidden and nonexistent resources. Verify the resource exists."},
|
|
67
68
|
) from exc
|
|
68
69
|
if "timeout" in msg.lower() or "Timeout" in msg:
|
|
69
70
|
raise ConfpubError(ERR_IO_TIMEOUT, f"Request timed out: {msg}") from exc
|
|
@@ -78,9 +79,9 @@ class ConfluenceClient:
|
|
|
78
79
|
) from exc
|
|
79
80
|
# Not found (404 or explicit "not found")
|
|
80
81
|
if "404" in msg or "not found" in msg.lower():
|
|
81
|
-
from confpub.errors import
|
|
82
|
+
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
82
83
|
raise ConfpubError(
|
|
83
|
-
|
|
84
|
+
ERR_VALIDATION_NOT_FOUND,
|
|
84
85
|
f"Resource not found ({context}): {msg}",
|
|
85
86
|
) from exc
|
|
86
87
|
raise ConfpubError(ERR_INTERNAL_SDK, f"Unexpected API error ({context}): {msg}") from exc
|
|
@@ -93,7 +94,7 @@ class ConfluenceClient:
|
|
|
93
94
|
"""Get a page by space key and title."""
|
|
94
95
|
self._call_count += 1
|
|
95
96
|
try:
|
|
96
|
-
result = self._api.get_page_by_title(space, title, expand="version,body.storage,space")
|
|
97
|
+
result = self._api.get_page_by_title(space, title, expand="version,body.storage,space,ancestors")
|
|
97
98
|
return result if result else None
|
|
98
99
|
except Exception as exc:
|
|
99
100
|
self._handle_error(exc, "get_page")
|
|
@@ -103,7 +104,7 @@ class ConfluenceClient:
|
|
|
103
104
|
"""Get a page by its Confluence ID."""
|
|
104
105
|
self._call_count += 1
|
|
105
106
|
try:
|
|
106
|
-
return self._api.get_page_by_id(page_id, expand="version,body.storage,space")
|
|
107
|
+
return self._api.get_page_by_id(page_id, expand="version,body.storage,space,ancestors")
|
|
107
108
|
except Exception as exc:
|
|
108
109
|
self._handle_error(exc, "get_page_by_id")
|
|
109
110
|
return {}
|
|
@@ -175,10 +176,11 @@ class ConfluenceClient:
|
|
|
175
176
|
"""Delete a page by space and title."""
|
|
176
177
|
page = self.get_page(space, title)
|
|
177
178
|
if not page:
|
|
178
|
-
from confpub.errors import
|
|
179
|
+
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
179
180
|
raise ConfpubError(
|
|
180
|
-
|
|
181
|
+
ERR_VALIDATION_NOT_FOUND,
|
|
181
182
|
f"Page '{title}' not found in space '{space}'",
|
|
183
|
+
retryable=False,
|
|
182
184
|
)
|
|
183
185
|
page_id = str(page["id"])
|
|
184
186
|
if cascade:
|
|
@@ -230,6 +232,16 @@ class ConfluenceClient:
|
|
|
230
232
|
start += limit
|
|
231
233
|
return all_children
|
|
232
234
|
|
|
235
|
+
def get_page_ancestors(self, page_id: str) -> list[dict[str, Any]]:
|
|
236
|
+
"""Get the ancestor chain of a page (root first, immediate parent last)."""
|
|
237
|
+
self._call_count += 1
|
|
238
|
+
try:
|
|
239
|
+
page = self._api.get_page_by_id(page_id, expand="ancestors")
|
|
240
|
+
return page.get("ancestors", [])
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
self._handle_error(exc, "get_page_ancestors")
|
|
243
|
+
return []
|
|
244
|
+
|
|
233
245
|
# ------------------------------------------------------------------
|
|
234
246
|
# Space operations
|
|
235
247
|
# ------------------------------------------------------------------
|
|
@@ -328,6 +340,9 @@ class ConfluenceClient:
|
|
|
328
340
|
try:
|
|
329
341
|
result = self._api.attach_file(filepath, page_id=page_id)
|
|
330
342
|
if isinstance(result, dict):
|
|
343
|
+
# API returns {"results": [...]} wrapper — extract the attachment
|
|
344
|
+
if "results" in result and isinstance(result["results"], list) and result["results"]:
|
|
345
|
+
return _slim_attachment(result["results"][0])
|
|
331
346
|
return _slim_attachment(result)
|
|
332
347
|
return {"uploaded": True, "file": filepath}
|
|
333
348
|
except Exception as exc:
|
|
@@ -354,13 +369,13 @@ class ConfluenceClient:
|
|
|
354
369
|
"""
|
|
355
370
|
self._call_count += 1
|
|
356
371
|
try:
|
|
357
|
-
raw = self._api.
|
|
358
|
-
cql,
|
|
359
|
-
start
|
|
360
|
-
limit
|
|
361
|
-
excerpt
|
|
362
|
-
include_archived_spaces
|
|
363
|
-
)
|
|
372
|
+
raw = self._api.get("rest/api/search", params={
|
|
373
|
+
"cql": cql,
|
|
374
|
+
"start": start,
|
|
375
|
+
"limit": limit,
|
|
376
|
+
"excerpt": "highlight",
|
|
377
|
+
"includeArchivedSpaces": include_archived_spaces,
|
|
378
|
+
})
|
|
364
379
|
except Exception as exc:
|
|
365
380
|
msg = str(exc)
|
|
366
381
|
if "400" in msg or "cannot be parsed" in msg.lower():
|
|
@@ -376,6 +391,9 @@ class ConfluenceClient:
|
|
|
376
391
|
results_raw = raw.get("results", []) if isinstance(raw, dict) else []
|
|
377
392
|
total = raw.get("totalSize", 0) if isinstance(raw, dict) else 0
|
|
378
393
|
|
|
394
|
+
api_start = raw.get("start", start) if isinstance(raw, dict) else start
|
|
395
|
+
api_limit = raw.get("limit", limit) if isinstance(raw, dict) else limit
|
|
396
|
+
|
|
379
397
|
results = [
|
|
380
398
|
_slim_search_result(r, base_url=base_url, excerpt_length=excerpt_length)
|
|
381
399
|
for r in results_raw
|
|
@@ -383,9 +401,9 @@ class ConfluenceClient:
|
|
|
383
401
|
return {
|
|
384
402
|
"results": results,
|
|
385
403
|
"total": total,
|
|
386
|
-
"start":
|
|
387
|
-
"limit":
|
|
388
|
-
"has_more": (
|
|
404
|
+
"start": api_start,
|
|
405
|
+
"limit": api_limit,
|
|
406
|
+
"has_more": (api_start + api_limit) < total,
|
|
389
407
|
}
|
|
390
408
|
|
|
391
409
|
# ------------------------------------------------------------------
|
|
@@ -419,9 +437,14 @@ def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
|
|
|
419
437
|
body = page.get("body", {}).get("storage", {}).get("value")
|
|
420
438
|
if body is not None:
|
|
421
439
|
result["body_storage"] = body
|
|
440
|
+
ancestors = page.get("ancestors")
|
|
441
|
+
if isinstance(ancestors, list) and ancestors:
|
|
442
|
+
parent = ancestors[-1]
|
|
443
|
+
result["parent_id"] = parent.get("id")
|
|
444
|
+
result["parent_title"] = parent.get("title")
|
|
422
445
|
links = page.get("_links", {})
|
|
423
446
|
if "webui" in links:
|
|
424
|
-
base = links.get("base", "")
|
|
447
|
+
base = base_url or links.get("base", "")
|
|
425
448
|
result["webui"] = base + links["webui"]
|
|
426
449
|
return result
|
|
427
450
|
|
|
@@ -65,7 +65,7 @@ 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
69
|
"agent_hint": "Most agent workflows should include --type page to exclude attachments and space entities from results.",
|
|
70
70
|
"result_schema": {
|
|
71
71
|
"cql_query": "string — effective CQL sent to the API",
|
|
@@ -79,6 +79,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
79
79
|
'confpub search --cql \'label = "api-docs"\'',
|
|
80
80
|
"confpub search --space DEV --type page --limit 10",
|
|
81
81
|
'confpub search --space DEV --cql \'title ~ "deploy"\'',
|
|
82
|
+
'confpub search --title "deploy guide" --space DEV',
|
|
82
83
|
],
|
|
83
84
|
},
|
|
84
85
|
"page.list": {
|
|
@@ -97,7 +98,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
97
98
|
"group": "write",
|
|
98
99
|
"mutates": True,
|
|
99
100
|
"description": "Publish a single Markdown file to Confluence",
|
|
100
|
-
"flags": ["--space", "--parent", "--title", "--dry-run", "--backup"],
|
|
101
|
+
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
|
|
101
102
|
},
|
|
102
103
|
"page.pull": {
|
|
103
104
|
"group": "read",
|
|
@@ -116,7 +117,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
116
117
|
"group": "write",
|
|
117
118
|
"mutates": True,
|
|
118
119
|
"description": "Delete a Confluence page",
|
|
119
|
-
"flags": ["--space", "--title", "--cascade"],
|
|
120
|
+
"flags": ["--space", "--title", "--page-id", "--cascade"],
|
|
120
121
|
"safety_flags": {
|
|
121
122
|
"--cascade": "Also deletes child pages",
|
|
122
123
|
},
|
|
@@ -248,6 +249,28 @@ def build_guide() -> dict[str, Any]:
|
|
|
248
249
|
"Does not prevent concurrent operations — purely local state tracking",
|
|
249
250
|
],
|
|
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
|
+
},
|
|
251
274
|
"auth": {
|
|
252
275
|
"precedence": [
|
|
253
276
|
"--token + --user",
|
|
@@ -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,
|
|
@@ -73,7 +74,14 @@ def publish_page(
|
|
|
73
74
|
existing_page_id = None
|
|
74
75
|
current_version = None
|
|
75
76
|
|
|
76
|
-
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:
|
|
77
85
|
existing_page_id = lockfile.pages[page_title].page_id
|
|
78
86
|
page_data = client.get_page_by_id(existing_page_id)
|
|
79
87
|
if page_data:
|
|
@@ -99,6 +107,19 @@ def publish_page(
|
|
|
99
107
|
# Determine operation
|
|
100
108
|
operation = "update" if existing_page_id else "create"
|
|
101
109
|
|
|
110
|
+
# Detect noop — skip update when content is unchanged
|
|
111
|
+
if operation == "update":
|
|
112
|
+
# Check lockfile fingerprint first (avoids Confluence normalization mismatch)
|
|
113
|
+
if page_title in lockfile.pages:
|
|
114
|
+
entry = lockfile.pages[page_title]
|
|
115
|
+
if entry.content_fingerprint and entry.content_fingerprint == local_fingerprint:
|
|
116
|
+
operation = "noop"
|
|
117
|
+
# Fallback: compare against remote fingerprint
|
|
118
|
+
if operation == "update":
|
|
119
|
+
remote_fingerprint = client.fingerprint_page(existing_page_id)
|
|
120
|
+
if remote_fingerprint and remote_fingerprint == local_fingerprint:
|
|
121
|
+
operation = "noop"
|
|
122
|
+
|
|
102
123
|
if dry_run:
|
|
103
124
|
change: dict[str, Any] = {
|
|
104
125
|
"type": f"page.{operation}",
|
|
@@ -124,6 +145,18 @@ def publish_page(
|
|
|
124
145
|
return result_dict
|
|
125
146
|
|
|
126
147
|
# Real publish
|
|
148
|
+
if operation == "noop":
|
|
149
|
+
return {
|
|
150
|
+
"dry_run": False,
|
|
151
|
+
"changes": [{
|
|
152
|
+
"type": "page.noop",
|
|
153
|
+
"title": page_title,
|
|
154
|
+
"confluence_page_id": existing_page_id,
|
|
155
|
+
"before": {"version": current_version} if current_version else None,
|
|
156
|
+
}],
|
|
157
|
+
"summary": {"noop": 1},
|
|
158
|
+
}
|
|
159
|
+
|
|
127
160
|
backup_file_path: str | None = None
|
|
128
161
|
if operation == "create":
|
|
129
162
|
# Find parent page
|
|
@@ -160,7 +193,9 @@ def publish_page(
|
|
|
160
193
|
uploaded_attachments = [a.source_path for a in assets]
|
|
161
194
|
|
|
162
195
|
# Update lockfile
|
|
163
|
-
|
|
196
|
+
new_version_int = new_version if isinstance(new_version, int) else 1
|
|
197
|
+
update_lockfile(lockfile, page_title, page_id, new_version_int)
|
|
198
|
+
lockfile.pages[page_title].content_fingerprint = local_fingerprint
|
|
164
199
|
save_lockfile(lockfile_path, lockfile)
|
|
165
200
|
|
|
166
201
|
change = {
|
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
14
|
from confpub.confluence import ConfluenceClient, build_client
|
|
15
|
-
from confpub.output import
|
|
15
|
+
from confpub.output import emit_progress
|
|
16
16
|
from confpub.errors import (
|
|
17
17
|
ERR_CONFLICT_FILE_EXISTS,
|
|
18
18
|
ERR_VALIDATION_NOT_FOUND,
|
|
@@ -47,7 +47,7 @@ def _collect_tree(
|
|
|
47
47
|
def _walk(pid: str, parent_id: str | None) -> None:
|
|
48
48
|
children = client.get_page_children_deep(pid)
|
|
49
49
|
if children:
|
|
50
|
-
|
|
50
|
+
emit_progress(0, 0, f"Found {len(children)} child page(s) under {pid}")
|
|
51
51
|
for child in children:
|
|
52
52
|
child_id = str(child["id"])
|
|
53
53
|
pages.append({
|
|
@@ -312,8 +312,11 @@ def pull_pages(
|
|
|
312
312
|
manifest_file: str | None = None
|
|
313
313
|
if generate_manifest:
|
|
314
314
|
root_title = root_page.get("title", "")
|
|
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
|
|
315
318
|
page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir)
|
|
316
|
-
manifest_yaml = generate_manifest_yaml(root_space,
|
|
319
|
+
manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
|
|
317
320
|
manifest_path = os.path.join(output_dir, "confpub.yaml")
|
|
318
321
|
Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
|
|
319
322
|
manifest_file = manifest_path
|
|
@@ -35,7 +35,6 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
35
35
|
raise ConfpubError(ERR_VALIDATION_MANIFEST, f"Invalid assertions JSON: {exc}") from exc
|
|
36
36
|
|
|
37
37
|
if plan_path:
|
|
38
|
-
# Try to load assertions from the plan's source manifest
|
|
39
38
|
p = Path(plan_path)
|
|
40
39
|
if not p.exists():
|
|
41
40
|
raise ConfpubError(
|
|
@@ -44,9 +43,33 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
44
43
|
retryable=False,
|
|
45
44
|
suggested_action="fix_input",
|
|
46
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
|
|
47
61
|
plan_data = json.loads(p.read_text(encoding="utf-8"))
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
50
73
|
|
|
51
74
|
return []
|
|
52
75
|
|
|
@@ -88,14 +111,22 @@ def verify_assertions(
|
|
|
88
111
|
all_passed = False
|
|
89
112
|
|
|
90
113
|
elif a_type == "page.parent":
|
|
114
|
+
space = assertion.get("space", "")
|
|
91
115
|
title = assertion.get("title", "")
|
|
92
116
|
expected_parent = assertion.get("expected_parent", "")
|
|
93
117
|
result["title"] = title
|
|
94
|
-
# Get page and check its parent
|
|
95
|
-
# This is a simplified check — in a full implementation
|
|
96
|
-
# we'd look up the page's ancestor chain
|
|
97
|
-
result["passed"] = True # Simplified for now
|
|
98
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
|
|
99
130
|
|
|
100
131
|
elif a_type == "attachment.exists":
|
|
101
132
|
page_title = assertion.get("page", "")
|
|
@@ -12,6 +12,7 @@ from confpub.errors import (
|
|
|
12
12
|
ERR_CONFLICT_PAGE_EXISTS,
|
|
13
13
|
ERR_IO_CONNECTION,
|
|
14
14
|
ERR_IO_FILE_NOT_FOUND,
|
|
15
|
+
ERR_VALIDATION_NOT_FOUND,
|
|
15
16
|
ERR_INTERNAL_SDK,
|
|
16
17
|
ERR_VALIDATION_REQUIRED,
|
|
17
18
|
ConfpubError,
|
|
@@ -52,7 +53,7 @@ class TestGetPage:
|
|
|
52
53
|
result = client.get_page("DEV", "Test")
|
|
53
54
|
assert result["id"] == "123"
|
|
54
55
|
client._mock_api.get_page_by_title.assert_called_once_with(
|
|
55
|
-
"DEV", "Test", expand="version,body.storage,space"
|
|
56
|
+
"DEV", "Test", expand="version,body.storage,space,ancestors"
|
|
56
57
|
)
|
|
57
58
|
|
|
58
59
|
def test_returns_none_when_not_found(self, client):
|
|
@@ -260,7 +261,7 @@ class TestErrorTranslation:
|
|
|
260
261
|
client._mock_api.get_all_spaces.side_effect = Exception("404 Not Found")
|
|
261
262
|
with pytest.raises(ConfpubError) as exc_info:
|
|
262
263
|
client.list_spaces()
|
|
263
|
-
assert exc_info.value.code ==
|
|
264
|
+
assert exc_info.value.code == ERR_VALIDATION_NOT_FOUND
|
|
264
265
|
|
|
265
266
|
def test_generic_error(self, client):
|
|
266
267
|
client._mock_api.get_all_spaces.side_effect = Exception("Something weird happened")
|
|
@@ -322,7 +323,7 @@ class TestSlimPage:
|
|
|
322
323
|
|
|
323
324
|
class TestSearch:
|
|
324
325
|
def test_structured_results(self, client):
|
|
325
|
-
client._mock_api.
|
|
326
|
+
client._mock_api.get.return_value = {
|
|
326
327
|
"results": [
|
|
327
328
|
{
|
|
328
329
|
"entityType": "content",
|
|
@@ -357,14 +358,14 @@ class TestSearch:
|
|
|
357
358
|
assert r["container_title"] == "Development"
|
|
358
359
|
|
|
359
360
|
def test_empty_results(self, client):
|
|
360
|
-
client._mock_api.
|
|
361
|
+
client._mock_api.get.return_value = {"results": [], "totalSize": 0}
|
|
361
362
|
result = client.search('title = "nonexistent"')
|
|
362
363
|
assert result["results"] == []
|
|
363
364
|
assert result["total"] == 0
|
|
364
365
|
assert result["has_more"] is False
|
|
365
366
|
|
|
366
367
|
def test_has_more_pagination(self, client):
|
|
367
|
-
client._mock_api.
|
|
368
|
+
client._mock_api.get.return_value = {
|
|
368
369
|
"results": [{"entityType": "content", "content": {"id": "1"}, "excerpt": ""}],
|
|
369
370
|
"totalSize": 50,
|
|
370
371
|
}
|
|
@@ -375,31 +376,34 @@ class TestSearch:
|
|
|
375
376
|
assert result["limit"] == 10
|
|
376
377
|
|
|
377
378
|
def test_invalid_cql_raises_validation(self, client):
|
|
378
|
-
client._mock_api.
|
|
379
|
+
client._mock_api.get.side_effect = Exception("400 The query cannot be parsed")
|
|
379
380
|
with pytest.raises(ConfpubError) as exc_info:
|
|
380
381
|
client.search("invalid!!")
|
|
381
382
|
assert exc_info.value.code == ERR_VALIDATION_REQUIRED
|
|
382
383
|
|
|
383
384
|
def test_auth_error(self, client):
|
|
384
|
-
client._mock_api.
|
|
385
|
+
client._mock_api.get.side_effect = Exception("401 Unauthorized")
|
|
385
386
|
with pytest.raises(ConfpubError) as exc_info:
|
|
386
387
|
client.search("type = page")
|
|
387
388
|
assert exc_info.value.code == ERR_AUTH_FORBIDDEN
|
|
388
389
|
|
|
389
390
|
def test_include_archived_passthrough(self, client):
|
|
390
|
-
client._mock_api.
|
|
391
|
+
client._mock_api.get.return_value = {"results": [], "totalSize": 0}
|
|
391
392
|
client.search("type = page", include_archived_spaces=True)
|
|
392
|
-
client._mock_api.
|
|
393
|
-
"
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
393
|
+
client._mock_api.get.assert_called_once_with(
|
|
394
|
+
"rest/api/search",
|
|
395
|
+
params={
|
|
396
|
+
"cql": "type = page",
|
|
397
|
+
"start": 0,
|
|
398
|
+
"limit": 25,
|
|
399
|
+
"excerpt": "highlight",
|
|
400
|
+
"includeArchivedSpaces": True,
|
|
401
|
+
},
|
|
398
402
|
)
|
|
399
403
|
|
|
400
404
|
def test_excerpt_truncation(self, client):
|
|
401
405
|
long_excerpt = "word " * 100 # 500 chars
|
|
402
|
-
client._mock_api.
|
|
406
|
+
client._mock_api.get.return_value = {
|
|
403
407
|
"results": [{"entityType": "content", "content": {"id": "1"}, "excerpt": long_excerpt}],
|
|
404
408
|
"totalSize": 1,
|
|
405
409
|
}
|
|
@@ -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
|
|
|
@@ -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.6.0/FEEDBACK.md
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
# confpub-cli v0.5.0 — Blind Test Feedback
|
|
2
|
-
|
|
3
|
-
Tested by: Claude Code (Opus 4.6) on 2026-03-01, Windows 11, via `uvx confpub-cli`.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Summary
|
|
8
|
-
|
|
9
|
-
confpub v0.5.0 is a well-designed, agent-friendly CLI. The structured JSON envelope, clear error taxonomy, and comprehensive `guide` command make it straightforward for an LLM agent to drive programmatically. The full publish/pull/delete lifecycle works correctly, and round-trip Markdown fidelity is excellent. This review covers what works well, what could be improved, and specific bugs encountered.
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## What Works Well
|
|
14
|
-
|
|
15
|
-
### Structured JSON Envelope
|
|
16
|
-
Every command returns the same `{ ok, command, target, result, warnings, errors, metrics }` shape. This is the single most important design decision for agent consumption — no output parsing required.
|
|
17
|
-
|
|
18
|
-
### Error Taxonomy
|
|
19
|
-
Typed error codes (`ERR_VALIDATION_*`, `ERR_AUTH_*`, `ERR_IO_*`, `ERR_CONFLICT_*`) with `retryable` and `suggested_action` fields make programmatic error handling trivial. Exit codes are consistent with the documented schema.
|
|
20
|
-
|
|
21
|
-
### `guide` Command
|
|
22
|
-
The machine-readable schema is comprehensive: command metadata, flags, safety annotations, concurrency rules, error codes, and auth precedence. The `--section` flag works for top-level keys (`commands`, `auth`, `error_codes`, `concurrency`).
|
|
23
|
-
|
|
24
|
-
### Markdown Round-Trip Fidelity
|
|
25
|
-
Published a file with headings, bullet lists, tables, blockquotes, and fenced code blocks. Pulled it back — the Markdown was virtually identical (only trivial whitespace differences in table alignment `| --- |` vs `|---------|`). This is a strong result.
|
|
26
|
-
|
|
27
|
-
### Publish Lifecycle
|
|
28
|
-
- `page.publish` with `--dry-run` correctly reports what would happen before writing.
|
|
29
|
-
- Re-publishing an existing page updates it in-place with version increment.
|
|
30
|
-
- `--backup` flag saves the previous HTML to `.confpub-backup-{id}.html`.
|
|
31
|
-
- `--title` override works as expected.
|
|
32
|
-
- `page.delete` cleanly removes the page.
|
|
33
|
-
|
|
34
|
-
### Recursive Pull + Manifest Generation
|
|
35
|
-
`page pull --recursive --manifest` correctly traverses child pages and generates a well-structured `confpub.yaml` manifest. The flat layout manifest correctly represents the page hierarchy with `children:` nesting.
|
|
36
|
-
|
|
37
|
-
### Plan Workflow
|
|
38
|
-
The `plan create` / `plan validate` / `plan apply --dry-run` workflow functions correctly. Plans include fingerprints for stale-state detection, which is a nice safety feature.
|
|
39
|
-
|
|
40
|
-
### Lock File
|
|
41
|
-
`confpub.lock` tracks page IDs and versions, providing local state awareness across commands.
|
|
42
|
-
|
|
43
|
-
### Auth & Config
|
|
44
|
-
`auth inspect` and `config inspect` return clear, useful information. Token masking in `config inspect` is a good security practice.
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
## Bugs
|
|
49
|
-
|
|
50
|
-
### 1. Stderr Message Leaks on Expected "Not Found" Lookups
|
|
51
|
-
**Severity: Medium**
|
|
52
|
-
|
|
53
|
-
When publishing a new page (or doing a dry-run), the CLI prints `Can't find '<title>' page on ...` to stderr before the JSON output. This happens because the tool checks whether the page exists first. For a *new* page, not finding it is the expected path — not an error. The message is confusing, especially during `--dry-run` where the user explicitly wants to preview a creation.
|
|
54
|
-
|
|
55
|
-
`--quiet` suppresses it, but agents shouldn't need `--quiet` for expected behavior.
|
|
56
|
-
|
|
57
|
-
**Suggestion:** Only emit this message at `--verbose` level, or suppress it when the subsequent operation succeeds.
|
|
58
|
-
|
|
59
|
-
### 2. Relative Paths Fail for `plan validate` and `plan apply`
|
|
60
|
-
**Severity: Medium**
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
uvx confpub-cli plan validate --plan plan-test/test-plan.json
|
|
64
|
-
# ERR_IO_FILE_NOT_FOUND: Plan file not found: plan-test/test-plan.json
|
|
65
|
-
|
|
66
|
-
uvx confpub-cli plan validate --plan "C:/Users/.../test-plan.json"
|
|
67
|
-
# Works fine
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Relative paths resolve correctly for `page publish` (the FILE argument) but not for `--plan` in plan commands. This is likely a `Path.resolve()` vs `Path()` issue.
|
|
71
|
-
|
|
72
|
-
### 3. `page inspect` `webui` Field Inconsistent Format
|
|
73
|
-
**Severity: Low**
|
|
74
|
-
|
|
75
|
-
- With `--space SD --title "..."`: returns relative URL `/spaces/SD/pages/327981/Test+Page`
|
|
76
|
-
- With `--page-id 327981`: returns absolute URL `https://...atlassian.net/wiki/spaces/SD/pages/327981/Test+Page`
|
|
77
|
-
|
|
78
|
-
Should be consistent — preferably always absolute, since the agent may not know the base URL.
|
|
79
|
-
|
|
80
|
-
### 4. `ERR_AUTH_FORBIDDEN` for Nonexistent Space
|
|
81
|
-
**Severity: Low**
|
|
82
|
-
|
|
83
|
-
```
|
|
84
|
-
uvx confpub-cli page inspect --space FAKESPACE --title "Test"
|
|
85
|
-
# ERR_AUTH_FORBIDDEN: "Permission denied (get_page)"
|
|
86
|
-
# suggested_action: "escalate"
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
A nonexistent space key returns a permission error rather than a "space not found" validation error. The `suggested_action` is `"escalate"` but the guide says ERR_AUTH_FORBIDDEN should suggest `"reauth"`. An agent following the guide would incorrectly attempt to re-authenticate.
|
|
90
|
-
|
|
91
|
-
### 5. `guide --section` Does Not List Valid Sections on Error
|
|
92
|
-
**Severity: Low**
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
uvx confpub-cli guide --section search
|
|
96
|
-
# ERR_VALIDATION_REQUIRED: "Unknown guide section: search"
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
The error doesn't tell you what the valid sections are. Adding `"valid_sections": ["commands", "auth", "error_codes", "concurrency", "compatibility"]` to the error details would save a round-trip.
|
|
100
|
-
|
|
101
|
-
### 6. Nested Layout Manifest Uses Ambiguous `file: index.md` for All Pages
|
|
102
|
-
**Severity: Medium**
|
|
103
|
-
|
|
104
|
-
When using `--layout nested`, every page gets `file: index.md` in the manifest without a directory prefix:
|
|
105
|
-
|
|
106
|
-
```yaml
|
|
107
|
-
pages:
|
|
108
|
-
- title: confpub v0.3.0 Blind Test Report
|
|
109
|
-
file: index.md
|
|
110
|
-
children:
|
|
111
|
-
- title: What Works Well
|
|
112
|
-
file: index.md
|
|
113
|
-
- title: Bugs and Issues
|
|
114
|
-
file: index.md
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
These are all `index.md` — a plan created from this manifest would have no way to distinguish them. The file paths should include the relative directory (e.g., `confpub-v030-blind-test-report/index.md`).
|
|
118
|
-
|
|
119
|
-
### 7. Nested Layout Doesn't Actually Nest Directories
|
|
120
|
-
**Severity: Low**
|
|
121
|
-
|
|
122
|
-
The `--layout nested` option creates a flat set of directories rather than truly nesting children inside parents:
|
|
123
|
-
|
|
124
|
-
```
|
|
125
|
-
pulled-nested/
|
|
126
|
-
confpub-v030-blind-test-report/index.md # parent
|
|
127
|
-
what-works-well/index.md # child (not nested inside parent dir)
|
|
128
|
-
bugs-and-issues/index.md # child
|
|
129
|
-
full-test-matrix/index.md # child
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Expected behavior for "nested" layout would place children inside the parent directory.
|
|
133
|
-
|
|
134
|
-
### 8. `--layout nested` Generates Manifest Without `--manifest` Flag
|
|
135
|
-
**Severity: Low**
|
|
136
|
-
|
|
137
|
-
Using `page pull --layout nested` generates a `confpub.yaml` even without the `--manifest` flag. The flat layout correctly requires `--manifest` to generate one.
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
|
|
141
|
-
## Suggestions (Not Bugs)
|
|
142
|
-
|
|
143
|
-
### Add `page.inspect --format markdown`
|
|
144
|
-
Currently `page inspect` returns raw Confluence storage XML. An option to return the reverse-converted Markdown would be useful for quick content review without pulling to a file.
|
|
145
|
-
|
|
146
|
-
### Add `search --type page` Default for Agent Usage
|
|
147
|
-
Agents almost always want pages, not attachments or space entities. Consider a default or a `--pages-only` shorthand.
|
|
148
|
-
|
|
149
|
-
### Document `confpub.lock` in `guide`
|
|
150
|
-
The lock file is created implicitly but isn't documented in the guide schema. Agents should know it exists and what it tracks.
|
|
151
|
-
|
|
152
|
-
### `ERR_IO_FILE_NOT_FOUND` Suggestion for Missing Source
|
|
153
|
-
When the source file doesn't exist, `retryable: true` with `suggested_action: "retry"` is misleading — a file that doesn't exist won't appear on retry. Consider `retryable: false` with `suggested_action: "fix_input"` for local file-not-found errors (vs. transient network errors).
|
|
154
|
-
|
|
155
|
-
---
|
|
156
|
-
|
|
157
|
-
## Test Matrix
|
|
158
|
-
|
|
159
|
-
| Command | Flags Tested | Result | Notes |
|
|
160
|
-
|---|---|---|---|
|
|
161
|
-
| `guide` | (none), `--section commands`, `--section auth`, `--section error_codes` | Pass | `--section` works for top-level keys |
|
|
162
|
-
| `guide --section` | invalid section name | Pass (with note) | Error lacks valid section list |
|
|
163
|
-
| `auth inspect` | (none) | Pass | |
|
|
164
|
-
| `config inspect` | (none) | Pass | Token correctly masked |
|
|
165
|
-
| `space list` | (none) | Pass | |
|
|
166
|
-
| `page list` | `--space` | Pass | |
|
|
167
|
-
| `page inspect` | `--space --title`, `--page-id` | Pass | webui format inconsistency |
|
|
168
|
-
| `page publish` | `--dry-run`, `--backup`, `--title` | Pass | stderr leak on new page |
|
|
169
|
-
| `page publish` (update) | `--backup` | Pass | Version incremented correctly |
|
|
170
|
-
| `page pull` | `--output`, `--force`, `--manifest` | Pass | |
|
|
171
|
-
| `page pull` | `--recursive`, `--layout flat` | Pass | |
|
|
172
|
-
| `page pull` | `--recursive`, `--layout nested` | Pass (with notes) | Manifest ambiguity, not truly nested |
|
|
173
|
-
| `page delete` | `--space --title` | Pass | |
|
|
174
|
-
| `search` | `--space`, `--limit`, `--cql` | Pass | |
|
|
175
|
-
| `plan create` | `--manifest`, `--output` | Pass | |
|
|
176
|
-
| `plan validate` | `--plan` (absolute path) | Pass | Relative path fails |
|
|
177
|
-
| `plan apply` | `--plan --dry-run` | Pass | |
|
|
178
|
-
| `attachment list` | `--page-id` | Pass | |
|
|
179
|
-
| `--quiet` | global flag | Pass | Suppresses stderr messages |
|
|
180
|
-
| `--verbose` | global flag | Pass | Adds diagnostics to metrics |
|
|
181
|
-
| Error: missing file | `page publish nonexistent.md` | Pass | Clear error |
|
|
182
|
-
| Error: missing page | `page inspect --title "..."` | Pass | |
|
|
183
|
-
| Error: missing args | `page publish` (no file) | Pass | |
|
|
184
|
-
| Error: missing space | `--space FAKESPACE` | Fail | Misleading ERR_AUTH_FORBIDDEN |
|
|
185
|
-
|
|
186
|
-
---
|
|
187
|
-
|
|
188
|
-
## Overall Assessment
|
|
189
|
-
|
|
190
|
-
**confpub v0.5.0 is production-ready for the core publish/pull/delete workflow.** The agent-first JSON design, error taxonomy, and guide command set a high bar for CLI ergonomics. The main areas for improvement are:
|
|
191
|
-
|
|
192
|
-
1. Fix relative path resolution for plan commands (blocks scripted workflows)
|
|
193
|
-
2. Fix the nested layout manifest ambiguity (blocks round-trip with nested trees)
|
|
194
|
-
3. Suppress the "Can't find" stderr noise on expected new-page creation
|
|
195
|
-
4. Normalize the `webui` field format
|
|
196
|
-
|
|
197
|
-
None of these are blockers for single-page publishing, which is the most common use case.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|