confpub-cli 1.1.0__tar.gz → 1.3.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-1.1.0 → confpub_cli-1.3.0}/PKG-INFO +1 -1
  2. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/applier.py +30 -2
  4. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/cli.py +150 -2
  5. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/confluence.py +135 -7
  6. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/errors.py +1 -0
  7. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/guide.py +42 -2
  8. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/manifest.py +12 -1
  9. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/planner.py +4 -1
  10. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/publish.py +29 -8
  11. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/puller.py +15 -2
  12. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_applier.py +85 -0
  13. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_confluence.py +165 -1
  14. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_guide.py +45 -3
  15. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_integration.py +101 -0
  16. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_manifest.py +83 -0
  17. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_publish.py +76 -0
  18. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/.github/workflows/publish.yml +0 -0
  19. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/.gitignore +0 -0
  20. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/CLAUDE.md +0 -0
  21. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/LICENSE +0 -0
  22. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/PRD.md +0 -0
  23. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/README.md +0 -0
  24. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/assets.py +0 -0
  25. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/config.py +0 -0
  26. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/converter.py +0 -0
  27. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/envelope.py +0 -0
  28. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/lockfile.py +0 -0
  29. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/output.py +0 -0
  30. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/py.typed +0 -0
  31. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/reverse_converter.py +0 -0
  32. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/validator.py +0 -0
  33. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/verifier.py +0 -0
  34. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub.lock +0 -0
  35. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/pyproject.toml +0 -0
  36. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/__init__.py +0 -0
  37. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/conftest.py +0 -0
  38. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_assets.py +0 -0
  39. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_config.py +0 -0
  40. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_converter.py +0 -0
  41. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_envelope.py +0 -0
  42. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_errors.py +0 -0
  43. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_lockfile.py +0 -0
  44. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_output.py +0 -0
  45. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_planner.py +0 -0
  46. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_puller.py +0 -0
  47. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_reverse_converter.py +0 -0
  48. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_validator.py +0 -0
  49. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_verifier.py +0 -0
  50. {confpub_cli-1.1.0 → confpub_cli-1.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.1.0
3
+ Version: 1.3.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__ = "1.1.0"
3
+ __version__ = "1.3.0"
@@ -13,7 +13,7 @@ from typing import Any
13
13
 
14
14
  from confpub.assets import AssetRef, discover_assets, rewrite_image_urls, upload_assets
15
15
  from confpub.config import load_config
16
- from confpub.confluence import ConfluenceClient
16
+ from confpub.confluence import ConfluenceClient, build_page_url
17
17
  from confpub.converter import convert_markdown, fingerprint_content
18
18
  from confpub.errors import ERR_CONFLICT_FINGERPRINT, ERR_IO_FILE_NOT_FOUND, ConfpubError
19
19
  from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
@@ -43,7 +43,7 @@ def apply_plan(
43
43
  lockfile = load_lockfile(lockfile_path) or Lockfile()
44
44
 
45
45
  changes: list[dict[str, Any]] = []
46
- counts = {"create": 0, "update": 0, "attachments_upload": 0}
46
+ counts = {"create": 0, "update": 0, "attachments_upload": 0, "labels_applied": 0}
47
47
 
48
48
  # Resolve parent page IDs by title
49
49
  parent_ids: dict[str, str] = {} # title → page_id
@@ -113,6 +113,10 @@ def apply_plan(
113
113
  new_version = new_version.get("number", 1)
114
114
  change["after"]["page_id"] = new_id
115
115
  change["after"]["version"] = new_version
116
+ change["after"]["webui"] = build_page_url(
117
+ config.base_url or "", config.is_cloud,
118
+ plan.space, new_id, page.title,
119
+ )
116
120
 
117
121
  # Upload attachments
118
122
  if assets:
@@ -124,9 +128,19 @@ def apply_plan(
124
128
  change["attachments_added"] = [a.source_path for a in assets]
125
129
  counts["attachments_upload"] += len(assets)
126
130
 
131
+ # Apply labels
132
+ if page.labels:
133
+ client.set_labels(new_id, page.labels)
134
+ change["labels_added"] = page.labels
135
+ counts["labels_applied"] += len(page.labels)
136
+
127
137
  # Update lockfile and parent tracking
128
138
  update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1, content_fingerprint=fingerprint_content(storage))
129
139
  parent_ids[page.title] = new_id
140
+ else:
141
+ # Dry-run: report labels
142
+ if page.labels:
143
+ change["labels_to_apply"] = page.labels
130
144
 
131
145
  counts["create"] += 1
132
146
  changes.append(change)
@@ -167,6 +181,10 @@ def apply_plan(
167
181
  if isinstance(new_version, dict):
168
182
  new_version = new_version.get("number", (before_version or 0) + 1)
169
183
  change["after"]["version"] = new_version
184
+ change["after"]["webui"] = build_page_url(
185
+ config.base_url or "", config.is_cloud,
186
+ plan.space, page.confluence_page_id, page.title,
187
+ )
170
188
 
171
189
  # Upload attachments
172
190
  if assets:
@@ -176,12 +194,22 @@ def apply_plan(
176
194
  change["attachments_added"] = [a.source_path for a in assets]
177
195
  counts["attachments_upload"] += len(assets)
178
196
 
197
+ # Apply labels
198
+ if page.labels:
199
+ client.set_labels(page.confluence_page_id, page.labels)
200
+ change["labels_added"] = page.labels
201
+ counts["labels_applied"] += len(page.labels)
202
+
179
203
  update_lockfile(
180
204
  lockfile, page.title, page.confluence_page_id,
181
205
  new_version if isinstance(new_version, int) else 1,
182
206
  content_fingerprint=fingerprint_content(storage),
183
207
  )
184
208
  parent_ids[page.title] = page.confluence_page_id
209
+ else:
210
+ # Dry-run: report labels
211
+ if page.labels:
212
+ change["labels_to_apply"] = page.labels
185
213
 
186
214
  counts["update"] += 1
187
215
  changes.append(change)
@@ -38,6 +38,8 @@ auth_app = typer.Typer(help="Authentication", callback=_group_callback)
38
38
  config_app = typer.Typer(help="Configuration", callback=_group_callback)
39
39
  space_app = typer.Typer(help="Space operations", callback=_group_callback)
40
40
  attachment_app = typer.Typer(help="Attachment operations", callback=_group_callback)
41
+ label_app = typer.Typer(help="Label operations", callback=_group_callback)
42
+ comment_app = typer.Typer(help="Comment operations", callback=_group_callback)
41
43
 
42
44
  # ---------------------------------------------------------------------------
43
45
  # Main app
@@ -55,6 +57,8 @@ app.add_typer(auth_app, name="auth")
55
57
  app.add_typer(config_app, name="config")
56
58
  app.add_typer(space_app, name="space")
57
59
  app.add_typer(attachment_app, name="attachment")
60
+ app.add_typer(label_app, name="label")
61
+ app.add_typer(comment_app, name="comment")
58
62
 
59
63
 
60
64
  def _version_callback(value: bool) -> None:
@@ -189,7 +193,7 @@ def page_list(
189
193
  from confpub.confluence import build_client, _slim_page
190
194
  client = build_client()
191
195
  pages = client.list_pages(space, start=start, limit=limit)
192
- ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/")) for p in pages]}
196
+ ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in pages]}
193
197
 
194
198
 
195
199
  @page_app.command("inspect")
@@ -217,7 +221,7 @@ def page_inspect(
217
221
  if raw:
218
222
  ctx.result = page
219
223
  else:
220
- result = _slim_page(page, base_url=client._config.base_url.rstrip("/"))
224
+ result = _slim_page(page, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud)
221
225
  if format == "markdown" and "body_storage" in result:
222
226
  from confpub.reverse_converter import convert_storage_to_markdown
223
227
  conversion = convert_storage_to_markdown(result["body_storage"])
@@ -239,6 +243,7 @@ def page_publish(
239
243
  page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
240
244
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
241
245
  backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
246
+ label: Optional[list[str]] = typer.Option(None, "--label", help="Label to apply (repeatable)"),
242
247
  ) -> None:
243
248
  """Publish a single Markdown file to Confluence."""
244
249
  from confpub.publish import derive_title
@@ -262,6 +267,7 @@ def page_publish(
262
267
  dry_run=dry_run,
263
268
  backup=backup,
264
269
  progress_callback=ctx,
270
+ labels=label or [],
265
271
  )
266
272
  ctx.result = result
267
273
 
@@ -351,6 +357,43 @@ def page_delete(
351
357
  ctx.result = result
352
358
 
353
359
 
360
+ @page_app.command("move")
361
+ def page_move(
362
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
363
+ target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
364
+ space: Optional[str] = typer.Option(None, "--space", help="Space key (required with --target-parent)"),
365
+ target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
366
+ ) -> None:
367
+ """Move a page under a new parent."""
368
+ target = {"page_id": page_id}
369
+ with command_context("page.move", target=target) as ctx:
370
+ if not target_parent and not target_parent_id:
371
+ raise ConfpubError(
372
+ "ERR_VALIDATION_REQUIRED",
373
+ "Either --target-parent or --target-parent-id is required",
374
+ )
375
+ if target_parent and not space:
376
+ raise ConfpubError(
377
+ "ERR_VALIDATION_REQUIRED",
378
+ "--space is required when using --target-parent",
379
+ )
380
+ from confpub.confluence import build_client
381
+ client = build_client()
382
+
383
+ if target_parent_id:
384
+ # Use target_id directly — more reliable, no title resolution needed
385
+ parent_page = client.get_page_by_id(target_parent_id)
386
+ if not parent_page or not parent_page.get("id"):
387
+ from confpub.errors import ERR_VALIDATION_NOT_FOUND
388
+ raise ConfpubError(ERR_VALIDATION_NOT_FOUND, f"Target parent page not found: {target_parent_id}")
389
+ resolved_space = parent_page.get("space", {}).get("key", space or "")
390
+ result = client.move_page(resolved_space, page_id, target_id=target_parent_id)
391
+ else:
392
+ result = client.move_page(space, page_id, target_title=target_parent)
393
+
394
+ ctx.result = result
395
+
396
+
354
397
  @space_app.command("list")
355
398
  def space_list() -> None:
356
399
  """List accessible Confluence spaces."""
@@ -481,6 +524,111 @@ def config_inspect() -> None:
481
524
  ctx.result = config.to_display_dict()
482
525
 
483
526
 
527
+ # ---------------------------------------------------------------------------
528
+ # Label commands
529
+ # ---------------------------------------------------------------------------
530
+
531
+
532
+ @label_app.command("list")
533
+ def label_list(
534
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
535
+ ) -> None:
536
+ """List labels on a Confluence page."""
537
+ with command_context("label.list", target={"page_id": page_id}) as ctx:
538
+ from confpub.confluence import build_client
539
+ client = build_client()
540
+ labels = client.get_labels(page_id)
541
+ ctx.result = {"labels": labels, "count": len(labels)}
542
+
543
+
544
+ @label_app.command("add")
545
+ def label_add(
546
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
547
+ label: list[str] = typer.Option(..., "--label", help="Label name (repeatable)"),
548
+ ) -> None:
549
+ """Add labels to a Confluence page."""
550
+ with command_context("label.add", target={"page_id": page_id}) as ctx:
551
+ from confpub.errors import ERR_VALIDATION_LABEL
552
+ # Validate labels
553
+ for lbl in label:
554
+ if " " in lbl:
555
+ raise ConfpubError(
556
+ ERR_VALIDATION_LABEL,
557
+ f"Label must not contain spaces: '{lbl}'",
558
+ details={"label": lbl},
559
+ )
560
+ if len(lbl) > 255:
561
+ raise ConfpubError(
562
+ ERR_VALIDATION_LABEL,
563
+ f"Label exceeds 255 characters: '{lbl[:50]}...'",
564
+ details={"label": lbl, "length": len(lbl)},
565
+ )
566
+
567
+ from confpub.confluence import build_client
568
+ client = build_client()
569
+ results = client.set_labels(page_id, label)
570
+ ctx.result = {"labels_added": label, "results": results}
571
+
572
+
573
+ @label_app.command("remove")
574
+ def label_remove(
575
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
576
+ label: list[str] = typer.Option(..., "--label", help="Label name to remove (repeatable)"),
577
+ ) -> None:
578
+ """Remove labels from a Confluence page."""
579
+ with command_context("label.remove", target={"page_id": page_id}) as ctx:
580
+ from confpub.confluence import build_client
581
+ client = build_client()
582
+ results = []
583
+ for lbl in label:
584
+ result = client.remove_label(page_id, lbl)
585
+ results.append(result)
586
+ ctx.result = {"labels_removed": label, "results": results}
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # Comment commands
591
+ # ---------------------------------------------------------------------------
592
+
593
+
594
+ @comment_app.command("add")
595
+ def comment_add(
596
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
597
+ text: Optional[str] = typer.Option(None, "--text", help="Comment text (Markdown)"),
598
+ file: Optional[str] = typer.Option(None, "--file", help="Path to Markdown file for comment body"),
599
+ ) -> None:
600
+ """Add a comment to a Confluence page."""
601
+ with command_context("comment.add", target={"page_id": page_id}) as ctx:
602
+ if not text and not file:
603
+ raise ConfpubError(
604
+ "ERR_VALIDATION_REQUIRED",
605
+ "Either --text or --file is required",
606
+ )
607
+ if text and file:
608
+ raise ConfpubError(
609
+ "ERR_VALIDATION_REQUIRED",
610
+ "--text and --file are mutually exclusive",
611
+ )
612
+
613
+ if file:
614
+ from pathlib import Path
615
+ p = Path(file)
616
+ if not p.exists():
617
+ from confpub.errors import ERR_IO_FILE_NOT_FOUND
618
+ raise ConfpubError(ERR_IO_FILE_NOT_FOUND, f"File not found: {file}")
619
+ md_text = p.read_text(encoding="utf-8")
620
+ else:
621
+ md_text = text
622
+
623
+ from confpub.converter import convert_markdown
624
+ storage_body = convert_markdown(md_text)
625
+
626
+ from confpub.confluence import build_client
627
+ client = build_client()
628
+ result = client.add_comment(page_id, storage_body)
629
+ ctx.result = result
630
+
631
+
484
632
  # ---------------------------------------------------------------------------
485
633
  # search command (top-level, not in a subgroup)
486
634
  # ---------------------------------------------------------------------------
@@ -267,7 +267,8 @@ class ConfluenceClient:
267
267
  raw = result
268
268
  else:
269
269
  raw = []
270
- return [_slim_space(s) for s in raw]
270
+ base_url = self._config.base_url.rstrip("/") if self._config.base_url else ""
271
+ return [_slim_space(s, base_url=base_url, is_cloud=self._config.is_cloud) for s in raw]
271
272
  except Exception as exc:
272
273
  self._handle_error(exc, "list_spaces")
273
274
  return []
@@ -407,7 +408,7 @@ class ConfluenceClient:
407
408
  api_limit = raw.get("limit", limit) if isinstance(raw, dict) else limit
408
409
 
409
410
  results = [
410
- _slim_search_result(r, base_url=base_url, excerpt_length=excerpt_length)
411
+ _slim_search_result(r, base_url=base_url, is_cloud=self._config.is_cloud, excerpt_length=excerpt_length)
411
412
  for r in results_raw
412
413
  ]
413
414
  return {
@@ -418,6 +419,95 @@ class ConfluenceClient:
418
419
  "has_more": (api_start + api_limit) < total,
419
420
  }
420
421
 
422
+ # ------------------------------------------------------------------
423
+ # Label operations
424
+ # ------------------------------------------------------------------
425
+
426
+ def get_labels(self, page_id: str) -> list[dict[str, Any]]:
427
+ """Get labels on a page."""
428
+ self._call_count += 1
429
+ try:
430
+ result = self._api.get_page_labels(page_id)
431
+ raw = result.get("results", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
432
+ return [_slim_label(lbl) for lbl in raw]
433
+ except Exception as exc:
434
+ self._handle_error(exc, "get_labels")
435
+ return []
436
+
437
+ def set_labels(self, page_id: str, labels: list[str]) -> list[dict[str, Any]]:
438
+ """Set labels on a page (additive — does not remove existing labels)."""
439
+ results: list[dict[str, Any]] = []
440
+ for lbl in labels:
441
+ self._call_count += 1
442
+ try:
443
+ result = self._api.set_page_label(page_id, lbl)
444
+ if isinstance(result, dict):
445
+ results.append(_slim_label(result))
446
+ elif isinstance(result, list):
447
+ results.extend(_slim_label(r) for r in result)
448
+ except Exception as exc:
449
+ self._handle_error(exc, "set_labels")
450
+ return results
451
+
452
+ def remove_label(self, page_id: str, label: str) -> dict[str, Any]:
453
+ """Remove a label from a page."""
454
+ self._call_count += 1
455
+ try:
456
+ self._api.remove_page_label(page_id, label)
457
+ return {"removed": True, "label": label, "page_id": page_id}
458
+ except Exception as exc:
459
+ self._handle_error(exc, "remove_label")
460
+ return {}
461
+
462
+ # ------------------------------------------------------------------
463
+ # Comment operations
464
+ # ------------------------------------------------------------------
465
+
466
+ def add_comment(self, page_id: str, body: str) -> dict[str, Any]:
467
+ """Add a comment to a page."""
468
+ self._call_count += 1
469
+ try:
470
+ result = self._api.add_comment(page_id, body)
471
+ return {
472
+ "id": result.get("id") if isinstance(result, dict) else None,
473
+ "page_id": page_id,
474
+ "created": True,
475
+ }
476
+ except Exception as exc:
477
+ self._handle_error(exc, "add_comment")
478
+ return {}
479
+
480
+ # ------------------------------------------------------------------
481
+ # Page move
482
+ # ------------------------------------------------------------------
483
+
484
+ def move_page(
485
+ self,
486
+ space_key: str,
487
+ page_id: str,
488
+ target_title: str | None = None,
489
+ target_id: str | None = None,
490
+ position: str = "append",
491
+ ) -> dict[str, Any]:
492
+ """Move a page under a new parent."""
493
+ self._call_count += 1
494
+ try:
495
+ result = self._api.move_page(
496
+ space_key, page_id,
497
+ target_title=target_title,
498
+ target_id=target_id,
499
+ position=position,
500
+ )
501
+ return {
502
+ "moved": True,
503
+ "page_id": page_id,
504
+ "target_parent": target_title or target_id,
505
+ "result": result if isinstance(result, dict) else {"raw": str(result)},
506
+ }
507
+ except Exception as exc:
508
+ self._handle_error(exc, "move_page")
509
+ return {}
510
+
421
511
  # ------------------------------------------------------------------
422
512
  # Fingerprinting
423
513
  # ------------------------------------------------------------------
@@ -433,7 +523,30 @@ class ConfluenceClient:
433
523
  return None
434
524
 
435
525
 
436
- def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
526
+ def _webui_base(base_url: str, is_cloud: bool) -> str:
527
+ """Return the correct base URL for webui links.
528
+
529
+ Cloud instances need ``/wiki`` in the path; DC/Server do not.
530
+ Handles base_url configured with or without the ``/wiki`` suffix.
531
+ """
532
+ base = base_url.rstrip("/")
533
+ if is_cloud and not base.endswith("/wiki"):
534
+ base += "/wiki"
535
+ return base
536
+
537
+
538
+ def build_page_url(
539
+ base_url: str, is_cloud: bool, space: str, page_id: str, title: str = "",
540
+ ) -> str:
541
+ """Construct a full webui URL for a Confluence page."""
542
+ base = _webui_base(base_url, is_cloud)
543
+ if title:
544
+ encoded_title = title.replace(" ", "+")
545
+ return f"{base}/spaces/{space}/pages/{page_id}/{encoded_title}"
546
+ return f"{base}/spaces/{space}/pages/{page_id}"
547
+
548
+
549
+ def _slim_page(page: dict[str, Any], *, base_url: str = "", is_cloud: bool = False) -> dict[str, Any]:
437
550
  """Extract agent-relevant fields from a raw Confluence page object."""
438
551
  result: dict[str, Any] = {
439
552
  "id": page.get("id"),
@@ -456,12 +569,12 @@ def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
456
569
  result["parent_title"] = parent.get("title")
457
570
  links = page.get("_links", {})
458
571
  if "webui" in links:
459
- base = base_url or links.get("base", "")
572
+ base = _webui_base(base_url, is_cloud) if base_url else links.get("base", "")
460
573
  result["webui"] = base + links["webui"]
461
574
  return result
462
575
 
463
576
 
464
- def _slim_space(space: dict[str, Any]) -> dict[str, Any]:
577
+ def _slim_space(space: dict[str, Any], *, base_url: str = "", is_cloud: bool = False) -> dict[str, Any]:
465
578
  """Extract agent-relevant fields from a raw Confluence space object."""
466
579
  result: dict[str, Any] = {
467
580
  "id": space.get("id"),
@@ -478,7 +591,12 @@ def _slim_space(space: dict[str, Any]) -> dict[str, Any]:
478
591
  result["description"] = value
479
592
  links = space.get("_links", {})
480
593
  if "webui" in links:
481
- result["webui"] = links.get("webui", "")
594
+ webui_path = links.get("webui", "")
595
+ if base_url and webui_path:
596
+ base = _webui_base(base_url, is_cloud)
597
+ result["webui"] = base + webui_path
598
+ else:
599
+ result["webui"] = webui_path
482
600
  return result
483
601
 
484
602
 
@@ -507,10 +625,20 @@ def _slim_attachment(att: dict[str, Any]) -> dict[str, Any]:
507
625
  return result
508
626
 
509
627
 
628
+ def _slim_label(lbl: dict[str, Any]) -> dict[str, Any]:
629
+ """Extract agent-relevant fields from a raw Confluence label object."""
630
+ return {
631
+ "name": lbl.get("name", ""),
632
+ "prefix": lbl.get("prefix", "global"),
633
+ "id": lbl.get("id"),
634
+ }
635
+
636
+
510
637
  def _slim_search_result(
511
638
  item: dict[str, Any],
512
639
  *,
513
640
  base_url: str = "",
641
+ is_cloud: bool = False,
514
642
  excerpt_length: int = 0,
515
643
  ) -> dict[str, Any]:
516
644
  """Extract agent-relevant fields from a raw Confluence search result.
@@ -536,7 +664,7 @@ def _slim_search_result(
536
664
 
537
665
  webui = content.get("_links", {}).get("webui", "")
538
666
  if webui:
539
- _set("url", base_url + webui)
667
+ _set("url", _webui_base(base_url, is_cloud) + webui if base_url else webui)
540
668
 
541
669
  _set("space_key", content.get("space", {}).get("key"))
542
670
 
@@ -15,6 +15,7 @@ ERR_VALIDATION_MARKDOWN = "ERR_VALIDATION_MARKDOWN"
15
15
  ERR_VALIDATION_ASSET_MISSING = "ERR_VALIDATION_ASSET_MISSING"
16
16
  ERR_VALIDATION_SPACE_MISMATCH = "ERR_VALIDATION_SPACE_MISMATCH"
17
17
  ERR_VALIDATION_NOT_FOUND = "ERR_VALIDATION_NOT_FOUND"
18
+ ERR_VALIDATION_LABEL = "ERR_VALIDATION_LABEL"
18
19
 
19
20
  # Auth (exit 20)
20
21
  ERR_AUTH_REQUIRED = "ERR_AUTH_REQUIRED"
@@ -24,6 +24,7 @@ from confpub.errors import (
24
24
  ERR_IO_FILE_NOT_FOUND,
25
25
  ERR_IO_TIMEOUT,
26
26
  ERR_VALIDATION_ASSET_MISSING,
27
+ ERR_VALIDATION_LABEL,
27
28
  ERR_VALIDATION_MANIFEST,
28
29
  ERR_VALIDATION_MARKDOWN,
29
30
  ERR_VALIDATION_NOT_FOUND,
@@ -121,11 +122,23 @@ def build_guide() -> dict[str, Any]:
121
122
  "group": "write",
122
123
  "mutates": True,
123
124
  "description": "Publish a single Markdown file to Confluence",
124
- "flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
125
+ "flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup", "--label"],
125
126
  "agent_hint": (
126
127
  "When --title is omitted, the title is inferred from the filename: "
127
128
  "the stem is extracted, hyphens and underscores are replaced with spaces, "
128
- "and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'."
129
+ "and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'. "
130
+ "Use --label to apply labels (repeatable): --label api --label docs."
131
+ ),
132
+ },
133
+ "page.move": {
134
+ "group": "write",
135
+ "mutates": True,
136
+ "description": "Move a page under a new parent",
137
+ "flags": ["--page-id", "--target-parent", "--space", "--target-parent-id"],
138
+ "agent_hint": (
139
+ "Use --target-parent + --space for title-based targeting, "
140
+ "or --target-parent-id for ID-based targeting. "
141
+ "The page ID does not change after a move."
129
142
  ),
130
143
  },
131
144
  "page.pull": {
@@ -177,6 +190,32 @@ def build_guide() -> dict[str, Any]:
177
190
  "description": "Upload an attachment to a Confluence page",
178
191
  "flags": ["--page-id"],
179
192
  },
193
+ "label.list": {
194
+ "group": "read",
195
+ "mutates": False,
196
+ "description": "List labels on a Confluence page",
197
+ "flags": ["--page-id"],
198
+ },
199
+ "label.add": {
200
+ "group": "write",
201
+ "mutates": True,
202
+ "description": "Add labels to a Confluence page",
203
+ "flags": ["--page-id", "--label"],
204
+ "agent_hint": "Use --label for each label (repeatable): --label api --label docs. Labels must not contain spaces and max 255 characters.",
205
+ },
206
+ "label.remove": {
207
+ "group": "write",
208
+ "mutates": True,
209
+ "description": "Remove labels from a Confluence page",
210
+ "flags": ["--page-id", "--label"],
211
+ },
212
+ "comment.add": {
213
+ "group": "write",
214
+ "mutates": True,
215
+ "description": "Add a comment to a Confluence page",
216
+ "flags": ["--page-id", "--text", "--file"],
217
+ "agent_hint": "Exactly one of --text or --file is required. The body is converted from Markdown to Confluence storage format.",
218
+ },
180
219
  "plan.create": {
181
220
  "group": "transactional",
182
221
  "mutates": False,
@@ -254,6 +293,7 @@ def build_guide() -> dict[str, Any]:
254
293
  ERR_VALIDATION_ASSET_MISSING: _error_code_entry(ERR_VALIDATION_ASSET_MISSING),
255
294
  ERR_VALIDATION_NOT_FOUND: _error_code_entry(ERR_VALIDATION_NOT_FOUND),
256
295
  ERR_VALIDATION_SPACE_MISMATCH: _error_code_entry(ERR_VALIDATION_SPACE_MISMATCH),
296
+ ERR_VALIDATION_LABEL: _error_code_entry(ERR_VALIDATION_LABEL),
257
297
  ERR_AUTH_REQUIRED: _error_code_entry(ERR_AUTH_REQUIRED),
258
298
  ERR_AUTH_EXPIRED: _error_code_entry(ERR_AUTH_EXPIRED),
259
299
  ERR_AUTH_FORBIDDEN: _error_code_entry(ERR_AUTH_FORBIDDEN),
@@ -51,6 +51,7 @@ class ManifestPage(BaseModel):
51
51
  title: str
52
52
  file: str
53
53
  assets: list[str] = Field(default_factory=list)
54
+ labels: list[str] = Field(default_factory=list)
54
55
  children: list[ManifestPage] = Field(default_factory=list)
55
56
 
56
57
 
@@ -93,6 +94,7 @@ class PlanPage(BaseModel):
93
94
  operation: Literal["create", "update", "noop"]
94
95
  parent_title: Optional[str] = None
95
96
  attachments: list[PlanAttachment] = Field(default_factory=list)
97
+ labels: list[str] = Field(default_factory=list)
96
98
 
97
99
 
98
100
  class PlanSummary(BaseModel):
@@ -102,6 +104,7 @@ class PlanSummary(BaseModel):
102
104
  update: int = 0
103
105
  noop: int = 0
104
106
  attachments_to_upload: int = 0
107
+ labels_to_apply: int = 0
105
108
 
106
109
 
107
110
  class PlanArtifact(BaseModel):
@@ -126,6 +129,7 @@ class FlatPage(BaseModel):
126
129
  title: str
127
130
  file: str
128
131
  assets: list[str] = Field(default_factory=list)
132
+ labels: list[str] = Field(default_factory=list) # merged global + per-page
129
133
  parent_title: str # The parent page title (from manifest parent or parent page)
130
134
 
131
135
 
@@ -187,6 +191,9 @@ def generate_manifest_yaml(
187
191
  result = []
188
192
  for p in pages:
189
193
  entry: dict[str, Any] = {"title": p["title"], "file": p["file"]}
194
+ labels = p.get("labels", [])
195
+ if labels:
196
+ entry["labels"] = labels
190
197
  children = p.get("children", [])
191
198
  if children:
192
199
  entry["children"] = _build_pages(children)
@@ -206,16 +213,20 @@ def resolve_page_tree(manifest: Manifest) -> list[FlatPage]:
206
213
  """Flatten the recursive page tree into a list with parent references.
207
214
 
208
215
  Pages are returned in tree order (parents before children) to ensure
209
- correct creation order.
216
+ correct creation order. Labels are merged: global (manifest.labels)
217
+ first, then per-page, deduplicated while preserving order.
210
218
  """
211
219
  flat: list[FlatPage] = []
220
+ global_labels = manifest.labels
212
221
 
213
222
  def _walk(pages: list[ManifestPage], parent_title: str) -> None:
214
223
  for page in pages:
224
+ merged = list(dict.fromkeys(global_labels + page.labels))
215
225
  flat.append(FlatPage(
216
226
  title=page.title,
217
227
  file=page.file,
218
228
  assets=page.assets,
229
+ labels=merged,
219
230
  parent_title=parent_title,
220
231
  ))
221
232
  if page.children:
@@ -58,7 +58,7 @@ def create_plan(
58
58
 
59
59
  # Build plan pages
60
60
  plan_pages: list[PlanPage] = []
61
- counts = {"create": 0, "update": 0, "noop": 0, "attachments_to_upload": 0}
61
+ counts = {"create": 0, "update": 0, "noop": 0, "attachments_to_upload": 0, "labels_to_apply": 0}
62
62
 
63
63
  for i, fp in enumerate(flat_pages):
64
64
  plan_id = f"plan_{i + 1}"
@@ -118,6 +118,8 @@ def create_plan(
118
118
 
119
119
  counts[operation] += 1
120
120
  counts["attachments_to_upload"] += len(plan_attachments)
121
+ if fp.labels:
122
+ counts["labels_to_apply"] += len(fp.labels)
121
123
 
122
124
  plan_pages.append(PlanPage(
123
125
  id=plan_id,
@@ -128,6 +130,7 @@ def create_plan(
128
130
  operation=operation,
129
131
  parent_title=fp.parent_title,
130
132
  attachments=plan_attachments,
133
+ labels=fp.labels,
131
134
  ))
132
135
 
133
136
  # Build plan artifact