confpub-cli 1.2.0__tar.gz → 1.4.1__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 (51) hide show
  1. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/PKG-INFO +1 -1
  2. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/applier.py +21 -1
  4. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/cli.py +189 -9
  5. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/confluence.py +133 -6
  6. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/converter.py +21 -0
  7. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/errors.py +1 -0
  8. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/guide.py +56 -2
  9. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/manifest.py +12 -1
  10. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/output.py +11 -0
  11. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/planner.py +4 -1
  12. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/publish.py +31 -8
  13. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/puller.py +18 -4
  14. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_applier.py +85 -0
  15. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_confluence.py +190 -2
  16. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_converter.py +29 -1
  17. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_guide.py +45 -3
  18. confpub_cli-1.4.1/tests/test_integration.py +449 -0
  19. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_manifest.py +83 -0
  20. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_output.py +16 -0
  21. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_publish.py +94 -0
  22. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_puller.py +50 -0
  23. confpub_cli-1.2.0/tests/test_integration.py +0 -222
  24. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/.github/workflows/publish.yml +0 -0
  25. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/.gitignore +0 -0
  26. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/CLAUDE.md +0 -0
  27. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/LICENSE +0 -0
  28. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/PRD.md +0 -0
  29. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/README.md +0 -0
  30. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/assets.py +0 -0
  31. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/config.py +0 -0
  32. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/envelope.py +0 -0
  33. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/lockfile.py +0 -0
  34. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/py.typed +0 -0
  35. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/reverse_converter.py +0 -0
  36. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/validator.py +0 -0
  37. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/verifier.py +0 -0
  38. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub.lock +0 -0
  39. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/pyproject.toml +0 -0
  40. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/__init__.py +0 -0
  41. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/conftest.py +0 -0
  42. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_assets.py +0 -0
  43. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_config.py +0 -0
  44. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_envelope.py +0 -0
  45. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_errors.py +0 -0
  46. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_lockfile.py +0 -0
  47. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_planner.py +0 -0
  48. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_reverse_converter.py +0 -0
  49. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_validator.py +0 -0
  50. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_verifier.py +0 -0
  51. {confpub_cli-1.2.0 → confpub_cli-1.4.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.2.0
3
+ Version: 1.4.1
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.2.0"
3
+ __version__ = "1.4.1"
@@ -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
@@ -128,9 +128,19 @@ def apply_plan(
128
128
  change["attachments_added"] = [a.source_path for a in assets]
129
129
  counts["attachments_upload"] += len(assets)
130
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
+
131
137
  # Update lockfile and parent tracking
132
138
  update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1, content_fingerprint=fingerprint_content(storage))
133
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
134
144
 
135
145
  counts["create"] += 1
136
146
  changes.append(change)
@@ -184,12 +194,22 @@ def apply_plan(
184
194
  change["attachments_added"] = [a.source_path for a in assets]
185
195
  counts["attachments_upload"] += len(assets)
186
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
+
187
203
  update_lockfile(
188
204
  lockfile, page.title, page.confluence_page_id,
189
205
  new_version if isinstance(new_version, int) else 1,
190
206
  content_fingerprint=fingerprint_content(storage),
191
207
  )
192
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
193
213
 
194
214
  counts["update"] += 1
195
215
  changes.append(change)
@@ -16,7 +16,7 @@ import typer
16
16
  from confpub import __version__
17
17
  from confpub.envelope import Envelope
18
18
  from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
19
- from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_verbose
19
+ from confpub.output import emit_stderr, emit_stdout, is_compact, is_verbose, set_compact, set_quiet, set_verbose
20
20
 
21
21
  # ---------------------------------------------------------------------------
22
22
  # Subcommand group apps
@@ -26,10 +26,12 @@ from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_
26
26
  def _group_callback(
27
27
  quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
28
28
  verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
29
+ compact: bool = typer.Option(False, "--compact", help="Output single-line JSON (no indentation)"),
29
30
  ) -> None:
30
- """Allow --quiet/--verbose between the group name and the subcommand."""
31
+ """Allow --quiet/--verbose/--compact between the group name and the subcommand."""
31
32
  set_quiet(quiet)
32
33
  set_verbose(verbose)
34
+ set_compact(compact)
33
35
 
34
36
 
35
37
  page_app = typer.Typer(help="Page operations", callback=_group_callback)
@@ -38,6 +40,8 @@ auth_app = typer.Typer(help="Authentication", callback=_group_callback)
38
40
  config_app = typer.Typer(help="Configuration", callback=_group_callback)
39
41
  space_app = typer.Typer(help="Space operations", callback=_group_callback)
40
42
  attachment_app = typer.Typer(help="Attachment operations", callback=_group_callback)
43
+ label_app = typer.Typer(help="Label operations", callback=_group_callback)
44
+ comment_app = typer.Typer(help="Comment operations", callback=_group_callback)
41
45
 
42
46
  # ---------------------------------------------------------------------------
43
47
  # Main app
@@ -55,6 +59,8 @@ app.add_typer(auth_app, name="auth")
55
59
  app.add_typer(config_app, name="config")
56
60
  app.add_typer(space_app, name="space")
57
61
  app.add_typer(attachment_app, name="attachment")
62
+ app.add_typer(label_app, name="label")
63
+ app.add_typer(comment_app, name="comment")
58
64
 
59
65
 
60
66
  def _version_callback(value: bool) -> None:
@@ -67,6 +73,7 @@ def _version_callback(value: bool) -> None:
67
73
  def main_callback(
68
74
  quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
69
75
  verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
76
+ compact: bool = typer.Option(False, "--compact", help="Output single-line JSON (no indentation)"),
70
77
  version: bool = typer.Option(
71
78
  False, "--version", help="Show version and exit",
72
79
  callback=_version_callback, is_eager=True,
@@ -75,6 +82,7 @@ def main_callback(
75
82
  """confpub — publish Markdown to Confluence."""
76
83
  set_quiet(quiet)
77
84
  set_verbose(verbose)
85
+ set_compact(compact)
78
86
 
79
87
 
80
88
  # ---------------------------------------------------------------------------
@@ -90,6 +98,7 @@ class CommandResult:
90
98
  self.target: dict[str, Any] | None = None
91
99
  self.warnings: list[str] = []
92
100
  self.metrics: dict[str, Any] = {}
101
+ self.client: Any = None
93
102
 
94
103
 
95
104
  @contextmanager
@@ -113,7 +122,10 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
113
122
  ctx.metrics["duration_ms"] = duration_ms
114
123
  if is_verbose():
115
124
  import traceback as tb
116
- ctx.metrics["diagnostics"] = {"traceback": tb.format_exc()}
125
+ err_diag: dict[str, Any] = {"traceback": tb.format_exc()}
126
+ if ctx.client and hasattr(ctx.client, "_call_count"):
127
+ err_diag["api_call_count"] = ctx.client._call_count
128
+ ctx.metrics["diagnostics"] = err_diag
117
129
  envelope = Envelope.failure(
118
130
  command_name,
119
131
  [e],
@@ -121,7 +133,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
121
133
  warnings=ctx.warnings,
122
134
  metrics=ctx.metrics,
123
135
  )
124
- emit_stdout(envelope.to_json_bytes())
136
+ emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
125
137
  raise typer.Exit(code=exit_code_for(e.code))
126
138
  except typer.Exit:
127
139
  raise
@@ -141,7 +153,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
141
153
  warnings=ctx.warnings,
142
154
  metrics=ctx.metrics,
143
155
  )
144
- emit_stdout(envelope.to_json_bytes())
156
+ emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
145
157
  raise typer.Exit(code=90)
146
158
  else:
147
159
  duration_ms = int((time.monotonic() - start) * 1000)
@@ -151,15 +163,20 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
151
163
  from confpub.config import load_config as _load_verbose_config
152
164
 
153
165
  diag: dict[str, Any] = {
166
+ "duration_ms": duration_ms,
154
167
  "command": command_name,
155
168
  "target": ctx.target,
156
169
  "warning_count": len(ctx.warnings),
157
170
  "python_version": sys.version,
158
171
  "confpub_version": __version__,
159
172
  }
173
+ if ctx.client and hasattr(ctx.client, "_call_count"):
174
+ diag["api_call_count"] = ctx.client._call_count
160
175
  try:
161
176
  _vcfg = _load_verbose_config()
177
+ diag["config_source"] = _vcfg.token_source
162
178
  diag["confluence_url"] = _vcfg.base_url
179
+ diag["is_cloud"] = _vcfg.is_cloud
163
180
  except Exception:
164
181
  pass
165
182
  ctx.metrics["diagnostics"] = diag
@@ -170,7 +187,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
170
187
  warnings=ctx.warnings,
171
188
  metrics=ctx.metrics,
172
189
  )
173
- emit_stdout(envelope.to_json_bytes())
190
+ emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
174
191
 
175
192
 
176
193
  # ---------------------------------------------------------------------------
@@ -188,8 +205,15 @@ def page_list(
188
205
  with command_context("page.list", target={"space": space}) as ctx:
189
206
  from confpub.confluence import build_client, _slim_page
190
207
  client = build_client()
191
- pages = client.list_pages(space, start=start, limit=limit)
192
- ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in pages]}
208
+ ctx.client = client
209
+ page_result = client.list_pages(space, start=start, limit=limit)
210
+ ctx.result = {
211
+ "pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in page_result["pages"]],
212
+ "start": page_result["start"],
213
+ "limit": page_result["limit"],
214
+ "size": page_result["size"],
215
+ "has_more": page_result["has_more"],
216
+ }
193
217
 
194
218
 
195
219
  @page_app.command("inspect")
@@ -204,6 +228,7 @@ def page_inspect(
204
228
  with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
205
229
  from confpub.confluence import build_client, _slim_page
206
230
  client = build_client()
231
+ ctx.client = client
207
232
  if page_id:
208
233
  page = client.get_page_by_id(page_id)
209
234
  else:
@@ -236,13 +261,15 @@ def page_publish(
236
261
  space: str = typer.Option(..., "--space", help="Confluence space key"),
237
262
  parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
238
263
  title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
264
+ title_from_h1: bool = typer.Option(False, "--title-from-h1", help="Derive title from first H1 heading in the Markdown file"),
239
265
  page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
240
266
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
241
267
  backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
268
+ label: Optional[list[str]] = typer.Option(None, "--label", help="Label to apply (repeatable)"),
242
269
  ) -> None:
243
270
  """Publish a single Markdown file to Confluence."""
244
271
  from confpub.publish import derive_title
245
- resolved_title = derive_title(file, title)
272
+ resolved_title = derive_title(file, title, title_from_h1=title_from_h1)
246
273
  target = {"space": space, "title": resolved_title, "file": file}
247
274
  if page_id:
248
275
  target["page_id"] = page_id
@@ -262,6 +289,7 @@ def page_publish(
262
289
  dry_run=dry_run,
263
290
  backup=backup,
264
291
  progress_callback=ctx,
292
+ labels=label or [],
265
293
  )
266
294
  ctx.result = result
267
295
 
@@ -319,6 +347,7 @@ def page_delete(
319
347
  )
320
348
  from confpub.confluence import build_client
321
349
  client = build_client()
350
+ ctx.client = client
322
351
 
323
352
  # Collect descendant IDs before deleting (for lockfile cleanup)
324
353
  deleted_ids: set[str] = set()
@@ -351,12 +380,51 @@ def page_delete(
351
380
  ctx.result = result
352
381
 
353
382
 
383
+ @page_app.command("move")
384
+ def page_move(
385
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
386
+ target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
387
+ space: Optional[str] = typer.Option(None, "--space", help="Space key (required with --target-parent)"),
388
+ target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
389
+ ) -> None:
390
+ """Move a page under a new parent."""
391
+ target = {"page_id": page_id}
392
+ with command_context("page.move", target=target) as ctx:
393
+ if not target_parent and not target_parent_id:
394
+ raise ConfpubError(
395
+ "ERR_VALIDATION_REQUIRED",
396
+ "Either --target-parent or --target-parent-id is required",
397
+ )
398
+ if target_parent and not space:
399
+ raise ConfpubError(
400
+ "ERR_VALIDATION_REQUIRED",
401
+ "--space is required when using --target-parent",
402
+ )
403
+ from confpub.confluence import build_client
404
+ client = build_client()
405
+ ctx.client = client
406
+
407
+ if target_parent_id:
408
+ # Use target_id directly — more reliable, no title resolution needed
409
+ parent_page = client.get_page_by_id(target_parent_id)
410
+ if not parent_page or not parent_page.get("id"):
411
+ from confpub.errors import ERR_VALIDATION_NOT_FOUND
412
+ raise ConfpubError(ERR_VALIDATION_NOT_FOUND, f"Target parent page not found: {target_parent_id}")
413
+ resolved_space = parent_page.get("space", {}).get("key", space or "")
414
+ result = client.move_page(resolved_space, page_id, target_id=target_parent_id)
415
+ else:
416
+ result = client.move_page(space, page_id, target_title=target_parent)
417
+
418
+ ctx.result = result
419
+
420
+
354
421
  @space_app.command("list")
355
422
  def space_list() -> None:
356
423
  """List accessible Confluence spaces."""
357
424
  with command_context("space.list") as ctx:
358
425
  from confpub.confluence import build_client
359
426
  client = build_client()
427
+ ctx.client = client
360
428
  spaces = client.list_spaces()
361
429
  ctx.result = {"spaces": spaces}
362
430
 
@@ -369,6 +437,7 @@ def attachment_list(
369
437
  with command_context("attachment.list", target={"page_id": page_id}) as ctx:
370
438
  from confpub.confluence import build_client, _slim_attachment
371
439
  client = build_client()
440
+ ctx.client = client
372
441
  attachments = client.get_attachments(page_id)
373
442
  ctx.result = {"attachments": [_slim_attachment(a) for a in attachments]}
374
443
 
@@ -382,6 +451,7 @@ def attachment_upload(
382
451
  with command_context("attachment.upload", target={"page_id": page_id, "file": file}) as ctx:
383
452
  from confpub.confluence import build_client
384
453
  client = build_client()
454
+ ctx.client = client
385
455
  result = client.upload_attachment(page_id, file)
386
456
  ctx.result = result
387
457
 
@@ -481,6 +551,115 @@ def config_inspect() -> None:
481
551
  ctx.result = config.to_display_dict()
482
552
 
483
553
 
554
+ # ---------------------------------------------------------------------------
555
+ # Label commands
556
+ # ---------------------------------------------------------------------------
557
+
558
+
559
+ @label_app.command("list")
560
+ def label_list(
561
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
562
+ ) -> None:
563
+ """List labels on a Confluence page."""
564
+ with command_context("label.list", target={"page_id": page_id}) as ctx:
565
+ from confpub.confluence import build_client
566
+ client = build_client()
567
+ ctx.client = client
568
+ labels = client.get_labels(page_id)
569
+ ctx.result = {"labels": labels, "count": len(labels)}
570
+
571
+
572
+ @label_app.command("add")
573
+ def label_add(
574
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
575
+ label: list[str] = typer.Option(..., "--label", help="Label name (repeatable)"),
576
+ ) -> None:
577
+ """Add labels to a Confluence page."""
578
+ with command_context("label.add", target={"page_id": page_id}) as ctx:
579
+ from confpub.errors import ERR_VALIDATION_LABEL
580
+ # Validate labels
581
+ for lbl in label:
582
+ if " " in lbl:
583
+ raise ConfpubError(
584
+ ERR_VALIDATION_LABEL,
585
+ f"Label must not contain spaces: '{lbl}'",
586
+ details={"label": lbl},
587
+ )
588
+ if len(lbl) > 255:
589
+ raise ConfpubError(
590
+ ERR_VALIDATION_LABEL,
591
+ f"Label exceeds 255 characters: '{lbl[:50]}...'",
592
+ details={"label": lbl, "length": len(lbl)},
593
+ )
594
+
595
+ from confpub.confluence import build_client
596
+ client = build_client()
597
+ ctx.client = client
598
+ results = client.set_labels(page_id, label)
599
+ ctx.result = {"labels_added": label, "results": results}
600
+
601
+
602
+ @label_app.command("remove")
603
+ def label_remove(
604
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
605
+ label: list[str] = typer.Option(..., "--label", help="Label name to remove (repeatable)"),
606
+ ) -> None:
607
+ """Remove labels from a Confluence page."""
608
+ with command_context("label.remove", target={"page_id": page_id}) as ctx:
609
+ from confpub.confluence import build_client
610
+ client = build_client()
611
+ ctx.client = client
612
+ results = []
613
+ for lbl in label:
614
+ result = client.remove_label(page_id, lbl)
615
+ results.append(result)
616
+ ctx.result = {"labels_removed": label, "results": results}
617
+
618
+
619
+ # ---------------------------------------------------------------------------
620
+ # Comment commands
621
+ # ---------------------------------------------------------------------------
622
+
623
+
624
+ @comment_app.command("add")
625
+ def comment_add(
626
+ page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
627
+ text: Optional[str] = typer.Option(None, "--text", help="Comment text (Markdown)"),
628
+ file: Optional[str] = typer.Option(None, "--file", help="Path to Markdown file for comment body"),
629
+ ) -> None:
630
+ """Add a comment to a Confluence page."""
631
+ with command_context("comment.add", target={"page_id": page_id}) as ctx:
632
+ if not text and not file:
633
+ raise ConfpubError(
634
+ "ERR_VALIDATION_REQUIRED",
635
+ "Either --text or --file is required",
636
+ )
637
+ if text and file:
638
+ raise ConfpubError(
639
+ "ERR_VALIDATION_REQUIRED",
640
+ "--text and --file are mutually exclusive",
641
+ )
642
+
643
+ if file:
644
+ from pathlib import Path
645
+ p = Path(file)
646
+ if not p.exists():
647
+ from confpub.errors import ERR_IO_FILE_NOT_FOUND
648
+ raise ConfpubError(ERR_IO_FILE_NOT_FOUND, f"File not found: {file}")
649
+ md_text = p.read_text(encoding="utf-8")
650
+ else:
651
+ md_text = text
652
+
653
+ from confpub.converter import convert_markdown
654
+ storage_body = convert_markdown(md_text)
655
+
656
+ from confpub.confluence import build_client
657
+ client = build_client()
658
+ ctx.client = client
659
+ result = client.add_comment(page_id, storage_body)
660
+ ctx.result = result
661
+
662
+
484
663
  # ---------------------------------------------------------------------------
485
664
  # search command (top-level, not in a subgroup)
486
665
  # ---------------------------------------------------------------------------
@@ -521,6 +700,7 @@ def search(
521
700
 
522
701
  from confpub.confluence import build_client
523
702
  client = build_client()
703
+ ctx.client = client
524
704
  result = client.search(
525
705
  effective_cql,
526
706
  start=start,
@@ -63,7 +63,7 @@ class ConfluenceClient:
63
63
  raise ConfpubError(
64
64
  ERR_AUTH_FORBIDDEN,
65
65
  f"Permission denied: {msg}",
66
- suggested_action="escalate",
66
+ suggested_action="check_input",
67
67
  details={"note": "Confluence returns HTTP 403 for both forbidden and nonexistent resources. Verify the resource exists."},
68
68
  ) from exc
69
69
  if "timeout" in msg.lower() or "Timeout" in msg:
@@ -75,6 +75,7 @@ class ConfluenceClient:
75
75
  raise ConfpubError(
76
76
  ERR_AUTH_FORBIDDEN,
77
77
  f"Permission denied ({context}): {msg}",
78
+ suggested_action="check_input",
78
79
  details={"note": "This may indicate a nonexistent resource; Confluence returns 403 for both."},
79
80
  ) from exc
80
81
  # Not found (404 or explicit "not found")
@@ -273,17 +274,27 @@ class ConfluenceClient:
273
274
  self._handle_error(exc, "list_spaces")
274
275
  return []
275
276
 
276
- def list_pages(self, space: str, *, start: int = 0, limit: int = 25) -> list[dict[str, Any]]:
277
- """List pages in a space."""
277
+ def list_pages(self, space: str, *, start: int = 0, limit: int = 25) -> dict[str, Any]:
278
+ """List pages in a space.
279
+
280
+ Returns a dict with keys: pages, start, limit, size, has_more.
281
+ """
278
282
  self._call_count += 1
279
283
  try:
280
284
  result = self._api.get_all_pages_from_space(
281
285
  space, start=start, limit=limit, expand="version",
282
286
  )
283
- return result if isinstance(result, list) else []
287
+ pages = result if isinstance(result, list) else []
288
+ return {
289
+ "pages": pages,
290
+ "start": start,
291
+ "limit": limit,
292
+ "size": len(pages),
293
+ "has_more": len(pages) >= limit,
294
+ }
284
295
  except Exception as exc:
285
296
  self._handle_error(exc, "list_pages")
286
- return []
297
+ return {"pages": [], "start": start, "limit": limit, "size": 0, "has_more": False}
287
298
 
288
299
  # ------------------------------------------------------------------
289
300
  # Attachment operations
@@ -351,7 +362,12 @@ class ConfluenceClient:
351
362
  """Upload an attachment to a page."""
352
363
  self._call_count += 1
353
364
  try:
354
- result = self._api.attach_file(filepath, page_id=page_id)
365
+ import mimetypes
366
+ content_type, _ = mimetypes.guess_type(filepath)
367
+ kwargs: dict[str, Any] = {"page_id": page_id}
368
+ if content_type:
369
+ kwargs["content_type"] = content_type
370
+ result = self._api.attach_file(filepath, **kwargs)
355
371
  if isinstance(result, dict):
356
372
  # API returns {"results": [...]} wrapper — extract the attachment
357
373
  if "results" in result and isinstance(result["results"], list) and result["results"]:
@@ -419,6 +435,108 @@ class ConfluenceClient:
419
435
  "has_more": (api_start + api_limit) < total,
420
436
  }
421
437
 
438
+ # ------------------------------------------------------------------
439
+ # Label operations
440
+ # ------------------------------------------------------------------
441
+
442
+ def get_labels(self, page_id: str) -> list[dict[str, Any]]:
443
+ """Get labels on a page."""
444
+ self._call_count += 1
445
+ try:
446
+ result = self._api.get_page_labels(page_id)
447
+ raw = result.get("results", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
448
+ return [_slim_label(lbl) for lbl in raw]
449
+ except Exception as exc:
450
+ self._handle_error(exc, "get_labels")
451
+ return []
452
+
453
+ def set_labels(self, page_id: str, labels: list[str]) -> list[dict[str, Any]]:
454
+ """Set labels on a page (additive — does not remove existing labels)."""
455
+ results: list[dict[str, Any]] = []
456
+ for lbl in labels:
457
+ self._call_count += 1
458
+ try:
459
+ result = self._api.set_page_label(page_id, lbl)
460
+ if isinstance(result, dict) and result.get("name"):
461
+ results.append(_slim_label(result))
462
+ elif isinstance(result, list):
463
+ results.extend(
464
+ _slim_label(r) if isinstance(r, dict) and r.get("name")
465
+ else {"name": lbl, "prefix": "global", "id": None}
466
+ for r in result
467
+ )
468
+ else:
469
+ results.append({"name": lbl, "prefix": "global", "id": None})
470
+ except Exception as exc:
471
+ self._handle_error(exc, "set_labels")
472
+ return results
473
+
474
+ def remove_label(self, page_id: str, label: str) -> dict[str, Any]:
475
+ """Remove a label from a page."""
476
+ self._call_count += 1
477
+ try:
478
+ self._api.remove_page_label(page_id, label)
479
+ return {"removed": True, "label": label, "page_id": page_id}
480
+ except Exception as exc:
481
+ self._handle_error(exc, "remove_label")
482
+ return {}
483
+
484
+ # ------------------------------------------------------------------
485
+ # Comment operations
486
+ # ------------------------------------------------------------------
487
+
488
+ def add_comment(self, page_id: str, body: str) -> dict[str, Any]:
489
+ """Add a comment to a page."""
490
+ self._call_count += 1
491
+ try:
492
+ result = self._api.add_comment(page_id, body)
493
+ return {
494
+ "id": result.get("id") if isinstance(result, dict) else None,
495
+ "page_id": page_id,
496
+ "created": True,
497
+ }
498
+ except Exception as exc:
499
+ self._handle_error(exc, "add_comment")
500
+ return {}
501
+
502
+ # ------------------------------------------------------------------
503
+ # Page move
504
+ # ------------------------------------------------------------------
505
+
506
+ def move_page(
507
+ self,
508
+ space_key: str,
509
+ page_id: str,
510
+ target_title: str | None = None,
511
+ target_id: str | None = None,
512
+ position: str = "append",
513
+ ) -> dict[str, Any]:
514
+ """Move a page under a new parent."""
515
+ self._call_count += 1
516
+ try:
517
+ result = self._api.move_page(
518
+ space_key, page_id,
519
+ target_title=target_title,
520
+ target_id=target_id,
521
+ position=position,
522
+ )
523
+ base_url = self._config.base_url.rstrip("/") if self._config.base_url else ""
524
+ page_data: dict[str, Any] | None = None
525
+ if isinstance(result, dict):
526
+ # The API may return the page directly or nested under a 'page' key
527
+ raw_page = result.get("page", result) if "page" in result else result
528
+ if raw_page.get("id"):
529
+ page_data = _slim_page(raw_page, base_url=base_url, is_cloud=self._config.is_cloud)
530
+ return {
531
+ "moved": True,
532
+ "page_id": page_id,
533
+ "target_parent": target_title or target_id,
534
+ "page": page_data,
535
+ }
536
+ except Exception as exc:
537
+ self._handle_error(exc, "move_page")
538
+ return {}
539
+
422
540
  # ------------------------------------------------------------------
423
541
  # Fingerprinting
424
542
  # ------------------------------------------------------------------
@@ -536,6 +654,15 @@ def _slim_attachment(att: dict[str, Any]) -> dict[str, Any]:
536
654
  return result
537
655
 
538
656
 
657
+ def _slim_label(lbl: dict[str, Any]) -> dict[str, Any]:
658
+ """Extract agent-relevant fields from a raw Confluence label object."""
659
+ return {
660
+ "name": lbl.get("name", ""),
661
+ "prefix": lbl.get("prefix", "global"),
662
+ "id": lbl.get("id"),
663
+ }
664
+
665
+
539
666
  def _slim_search_result(
540
667
  item: dict[str, Any],
541
668
  *,
@@ -366,6 +366,27 @@ def convert_markdown(md_text: str) -> str:
366
366
  return renderer.render(tokens, {}, {})
367
367
 
368
368
 
369
+ def extract_h1_title(md_text: str) -> str | None:
370
+ """Extract the text of the first H1 heading from Markdown source.
371
+
372
+ Returns the title string, or None if no H1 is found.
373
+ """
374
+ parser = _create_parser()
375
+ tokens = parser.parse(md_text)
376
+ for i, token in enumerate(tokens):
377
+ if token.type == "heading_open" and token.tag == "h1":
378
+ # The next token should be an inline token with the heading content
379
+ if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
380
+ inline = tokens[i + 1]
381
+ if inline.children:
382
+ parts: list[str] = []
383
+ for child in inline.children:
384
+ if child.type in ("text", "code_inline"):
385
+ parts.append(child.content)
386
+ return "".join(parts) if parts else None
387
+ return None
388
+
389
+
369
390
  def fingerprint_content(content: str) -> str:
370
391
  """Return SHA-256 hex digest of content."""
371
392
  return hashlib.sha256(content.encode("utf-8")).hexdigest()
@@ -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"