confpub-cli 0.6.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.6.0 → confpub_cli-0.7.0}/PKG-INFO +1 -1
  2. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/cli.py +31 -8
  4. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/confluence.py +18 -3
  5. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/guide.py +26 -3
  6. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/publish.py +27 -1
  7. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/puller.py +4 -1
  8. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/verifier.py +38 -7
  9. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_confluence.py +1 -1
  10. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_puller.py +4 -0
  11. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_verifier.py +2 -1
  12. confpub_cli-0.6.0/FEEDBACK.md +0 -197
  13. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/.github/workflows/publish.yml +0 -0
  14. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/.gitignore +0 -0
  15. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/LICENSE +0 -0
  16. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/PRD.md +0 -0
  17. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/README.md +0 -0
  18. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/applier.py +0 -0
  19. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/assets.py +0 -0
  20. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/config.py +0 -0
  21. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/converter.py +0 -0
  22. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/envelope.py +0 -0
  23. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/errors.py +0 -0
  24. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/lockfile.py +0 -0
  25. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/manifest.py +0 -0
  26. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/output.py +0 -0
  27. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/planner.py +0 -0
  28. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/py.typed +0 -0
  29. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/reverse_converter.py +0 -0
  30. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/validator.py +0 -0
  31. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub.lock +0 -0
  32. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/pyproject.toml +0 -0
  33. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/__init__.py +0 -0
  34. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/conftest.py +0 -0
  35. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_applier.py +0 -0
  36. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_assets.py +0 -0
  37. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_config.py +0 -0
  38. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_converter.py +0 -0
  39. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_envelope.py +0 -0
  40. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_errors.py +0 -0
  41. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_guide.py +0 -0
  42. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_integration.py +0 -0
  43. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_lockfile.py +0 -0
  44. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_manifest.py +0 -0
  45. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_output.py +0 -0
  46. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_planner.py +0 -0
  47. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_publish.py +0 -0
  48. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_reverse_converter.py +0 -0
  49. {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_validator.py +0 -0
  50. {confpub_cli-0.6.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.6.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.6.0"
3
+ __version__ = "0.7.0"
@@ -222,22 +222,31 @@ 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:
230
231
  """Publish a single Markdown file to Confluence."""
231
232
  from confpub.publish import derive_title
232
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
+ )
233
239
  target = {"space": space, "title": resolved_title, "file": file}
240
+ if page_id:
241
+ target["page_id"] = page_id
234
242
  with command_context("page.publish", target=target) as ctx:
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)
@@ -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
  # ------------------------------------------------------------------
@@ -419,9 +429,14 @@ def _slim_page(page: dict[str, Any], *, base_url: str = "") -> 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", "") or base_url
439
+ base = base_url or links.get("base", "")
425
440
  result["webui"] = base + links["webui"]
426
441
  return result
427
442
 
@@ -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 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,12 @@ 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
+ remote_fingerprint = client.fingerprint_page(existing_page_id)
113
+ if remote_fingerprint and remote_fingerprint == local_fingerprint:
114
+ operation = "noop"
115
+
102
116
  if dry_run:
103
117
  change: dict[str, Any] = {
104
118
  "type": f"page.{operation}",
@@ -124,6 +138,18 @@ def publish_page(
124
138
  return result_dict
125
139
 
126
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
+
127
153
  backup_file_path: str | None = None
128
154
  if operation == "create":
129
155
  # Find parent page
@@ -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", "")
@@ -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):
@@ -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