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.
Files changed (50) hide show
  1. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/PKG-INFO +1 -1
  2. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/cli.py +35 -8
  4. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/confluence.py +40 -17
  5. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/guide.py +26 -3
  6. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/lockfile.py +1 -0
  7. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/publish.py +37 -2
  8. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/puller.py +6 -3
  9. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/verifier.py +38 -7
  10. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_confluence.py +19 -15
  11. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_puller.py +4 -0
  12. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_verifier.py +2 -1
  13. confpub_cli-0.6.0/FEEDBACK.md +0 -197
  14. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/.github/workflows/publish.yml +0 -0
  15. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/.gitignore +0 -0
  16. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/LICENSE +0 -0
  17. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/PRD.md +0 -0
  18. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/README.md +0 -0
  19. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/applier.py +0 -0
  20. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/assets.py +0 -0
  21. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/config.py +0 -0
  22. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/converter.py +0 -0
  23. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/envelope.py +0 -0
  24. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/errors.py +0 -0
  25. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/manifest.py +0 -0
  26. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/output.py +0 -0
  27. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/planner.py +0 -0
  28. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/py.typed +0 -0
  29. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/reverse_converter.py +0 -0
  30. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub/validator.py +0 -0
  31. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/confpub.lock +0 -0
  32. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/pyproject.toml +0 -0
  33. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/__init__.py +0 -0
  34. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/conftest.py +0 -0
  35. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_applier.py +0 -0
  36. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_assets.py +0 -0
  37. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_config.py +0 -0
  38. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_converter.py +0 -0
  39. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_envelope.py +0 -0
  40. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_errors.py +0 -0
  41. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_guide.py +0 -0
  42. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_integration.py +0 -0
  43. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_lockfile.py +0 -0
  44. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_manifest.py +0 -0
  45. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_output.py +0 -0
  46. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_planner.py +0 -0
  47. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_publish.py +0 -0
  48. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_reverse_converter.py +0 -0
  49. {confpub_cli-0.6.0 → confpub_cli-0.8.0}/tests/test_validator.py +0 -0
  50. {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.6.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
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.8.0"
@@ -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(..., "--parent", help="Parent page title"),
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(..., "--space", help="Confluence space key"),
288
- 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"),
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
- 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)
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 ERR_IO_FILE_NOT_FOUND
82
+ from confpub.errors import ERR_VALIDATION_NOT_FOUND
82
83
  raise ConfpubError(
83
- ERR_IO_FILE_NOT_FOUND,
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 ERR_IO_FILE_NOT_FOUND
179
+ from confpub.errors import ERR_VALIDATION_NOT_FOUND
179
180
  raise ConfpubError(
180
- ERR_IO_FILE_NOT_FOUND,
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.cql(
358
- cql,
359
- start=start,
360
- limit=limit,
361
- excerpt="highlight",
362
- include_archived_spaces=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": start,
387
- "limit": limit,
388
- "has_more": (start + limit) < total,
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", "") or base_url
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",
@@ -21,6 +21,7 @@ class LockPageEntry(BaseModel):
21
21
 
22
22
  page_id: str
23
23
  version: int = 1
24
+ content_fingerprint: str | None = None
24
25
 
25
26
 
26
27
  class Lockfile(BaseModel):
@@ -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 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:
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
- update_lockfile(lockfile, page_title, page_id, new_version if isinstance(new_version, int) else 1)
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 emit_stderr
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
- emit_stderr(f"Found {len(children)} child page(s) under {pid}")
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, root_title, page_tree)
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
- # Plan doesn't contain assertions directly — return empty
49
- 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
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 == ERR_IO_FILE_NOT_FOUND
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.cql.return_value = {
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.cql.return_value = {"results": [], "totalSize": 0}
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.cql.return_value = {
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.cql.side_effect = Exception("400 The query cannot be parsed")
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.cql.side_effect = Exception("401 Unauthorized")
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.cql.return_value = {"results": [], "totalSize": 0}
391
+ client._mock_api.get.return_value = {"results": [], "totalSize": 0}
391
392
  client.search("type = page", include_archived_spaces=True)
392
- client._mock_api.cql.assert_called_once_with(
393
- "type = page",
394
- start=0,
395
- limit=25,
396
- excerpt="highlight",
397
- include_archived_spaces=True,
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.cql.return_value = {
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
 
@@ -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