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.
Files changed (50) hide show
  1. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/PKG-INFO +1 -1
  2. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/applier.py +2 -0
  4. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/cli.py +48 -10
  5. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/confluence.py +22 -7
  6. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/guide.py +43 -4
  7. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/manifest.py +6 -1
  8. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/planner.py +2 -0
  9. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/publish.py +29 -1
  10. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/puller.py +11 -6
  11. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/validator.py +7 -2
  12. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/verifier.py +50 -9
  13. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_confluence.py +1 -1
  14. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_guide.py +1 -1
  15. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_puller.py +7 -1
  16. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_verifier.py +2 -1
  17. confpub_cli-0.5.0/FEEDBACK.md +0 -226
  18. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/.github/workflows/publish.yml +0 -0
  19. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/.gitignore +0 -0
  20. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/LICENSE +0 -0
  21. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/PRD.md +0 -0
  22. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/README.md +0 -0
  23. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/assets.py +0 -0
  24. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/config.py +0 -0
  25. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/converter.py +0 -0
  26. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/envelope.py +0 -0
  27. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/errors.py +0 -0
  28. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/lockfile.py +0 -0
  29. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/output.py +0 -0
  30. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/py.typed +0 -0
  31. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub/reverse_converter.py +0 -0
  32. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/confpub.lock +0 -0
  33. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/pyproject.toml +0 -0
  34. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/__init__.py +0 -0
  35. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/conftest.py +0 -0
  36. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_applier.py +0 -0
  37. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_assets.py +0 -0
  38. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_config.py +0 -0
  39. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_converter.py +0 -0
  40. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_envelope.py +0 -0
  41. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_errors.py +0 -0
  42. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_integration.py +0 -0
  43. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_lockfile.py +0 -0
  44. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_manifest.py +0 -0
  45. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_output.py +0 -0
  46. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_planner.py +0 -0
  47. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_publish.py +0 -0
  48. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_reverse_converter.py +0 -0
  49. {confpub_cli-0.5.0 → confpub_cli-0.7.0}/tests/test_validator.py +0 -0
  50. {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.5.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
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
@@ -62,6 +62,8 @@ def apply_plan(
62
62
  raise ConfpubError(
63
63
  ERR_IO_FILE_NOT_FOUND,
64
64
  f"Source file missing: {page.source_file}",
65
+ retryable=False,
66
+ suggested_action="fix_input",
65
67
  )
66
68
 
67
69
  # Fingerprint check (unless skipped)
@@ -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
- ctx.result = page if raw else _slim_page(page)
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(..., "--parent", help="Parent page title"),
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(..., "--space", help="Confluence space key"),
274
- title: str = typer.Option(..., "--title", help="Page title"),
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
- result = client.delete_page_by_title(space, title, cascade=cascade)
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 is_quiet
35
+ from confpub.output import is_verbose
36
36
 
37
37
  atlassian_logger = logging.getLogger("atlassian")
38
- atlassian_logger.setLevel(logging.CRITICAL if is_quiet() else logging.WARNING)
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
- suggested_action="escalate",
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(ERR_IO_FILE_NOT_FOUND, f"Manifest not found: {path}")
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):
@@ -70,6 +70,8 @@ def create_plan(
70
70
  ERR_IO_FILE_NOT_FOUND,
71
71
  f"Source file not found: {fp.file}",
72
72
  details={"file": fp.file},
73
+ retryable=False,
74
+ suggested_action="fix_input",
73
75
  )
74
76
 
75
77
  # Read and convert markdown
@@ -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 page_title in lockfile.pages:
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
- if pid == root_page_id:
111
- chain.append(slug)
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.basename(file_path) if file_path else "",
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 or (recursive and len(all_pages) > 1):
313
+ if generate_manifest:
312
314
  root_title = root_page.get("title", "")
313
- page_tree = _build_page_tree(all_pages, file_paths, root_id)
314
- manifest_yaml = generate_manifest_yaml(root_space, root_title, page_tree)
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(ERR_IO_FILE_NOT_FOUND, f"Plan file not found: {plan_path}")
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(ERR_IO_FILE_NOT_FOUND, f"Assertions file not found: {assertions_path}")
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(ERR_IO_FILE_NOT_FOUND, f"Plan file not found: {plan_path}")
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
- # Plan doesn't contain assertions directly — return empty
39
- return []
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
 
@@ -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