confpub-cli 1.3.0__tar.gz → 1.4.2__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.3.0 → confpub_cli-1.4.2}/PKG-INFO +2 -2
  2. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/cli.py +41 -9
  4. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/confluence.py +38 -9
  5. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/converter.py +21 -0
  6. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/guide.py +15 -1
  7. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/output.py +11 -0
  8. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/publish.py +11 -2
  9. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/puller.py +3 -2
  10. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/pyproject.toml +1 -1
  11. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_confluence.py +91 -2
  12. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_converter.py +29 -1
  13. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_integration.py +127 -1
  14. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_output.py +16 -0
  15. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_publish.py +18 -0
  16. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_puller.py +50 -0
  17. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/.github/workflows/publish.yml +0 -0
  18. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/.gitignore +0 -0
  19. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/CLAUDE.md +0 -0
  20. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/LICENSE +0 -0
  21. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/PRD.md +0 -0
  22. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/README.md +0 -0
  23. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/applier.py +0 -0
  24. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/assets.py +0 -0
  25. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/config.py +0 -0
  26. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/envelope.py +0 -0
  27. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/errors.py +0 -0
  28. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/lockfile.py +0 -0
  29. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/manifest.py +0 -0
  30. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/planner.py +0 -0
  31. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/py.typed +0 -0
  32. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/reverse_converter.py +0 -0
  33. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/validator.py +0 -0
  34. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub/verifier.py +0 -0
  35. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/confpub.lock +0 -0
  36. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/__init__.py +0 -0
  37. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/conftest.py +0 -0
  38. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_applier.py +0 -0
  39. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_assets.py +0 -0
  40. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_config.py +0 -0
  41. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_envelope.py +0 -0
  42. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_errors.py +0 -0
  43. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_guide.py +0 -0
  44. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_lockfile.py +0 -0
  45. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_manifest.py +0 -0
  46. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_planner.py +0 -0
  47. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_reverse_converter.py +0 -0
  48. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_validator.py +0 -0
  49. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/tests/test_verifier.py +0 -0
  50. {confpub_cli-1.3.0 → confpub_cli-1.4.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.3.0
3
+ Version: 1.4.2
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
@@ -32,7 +32,7 @@ Requires-Dist: markdownify>=0.14
32
32
  Requires-Dist: orjson>=3.9
33
33
  Requires-Dist: pydantic>=2.0
34
34
  Requires-Dist: pyyaml>=6.0
35
- Requires-Dist: typer[all]>=0.9
35
+ Requires-Dist: typer>=0.9
36
36
  Provides-Extra: dev
37
37
  Requires-Dist: hatch>=1.0; extra == 'dev'
38
38
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.4.2"
@@ -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)
@@ -71,6 +73,7 @@ def _version_callback(value: bool) -> None:
71
73
  def main_callback(
72
74
  quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
73
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)"),
74
77
  version: bool = typer.Option(
75
78
  False, "--version", help="Show version and exit",
76
79
  callback=_version_callback, is_eager=True,
@@ -79,6 +82,7 @@ def main_callback(
79
82
  """confpub — publish Markdown to Confluence."""
80
83
  set_quiet(quiet)
81
84
  set_verbose(verbose)
85
+ set_compact(compact)
82
86
 
83
87
 
84
88
  # ---------------------------------------------------------------------------
@@ -94,6 +98,7 @@ class CommandResult:
94
98
  self.target: dict[str, Any] | None = None
95
99
  self.warnings: list[str] = []
96
100
  self.metrics: dict[str, Any] = {}
101
+ self.client: Any = None
97
102
 
98
103
 
99
104
  @contextmanager
@@ -117,7 +122,10 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
117
122
  ctx.metrics["duration_ms"] = duration_ms
118
123
  if is_verbose():
119
124
  import traceback as tb
120
- 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
121
129
  envelope = Envelope.failure(
122
130
  command_name,
123
131
  [e],
@@ -125,7 +133,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
125
133
  warnings=ctx.warnings,
126
134
  metrics=ctx.metrics,
127
135
  )
128
- emit_stdout(envelope.to_json_bytes())
136
+ emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
129
137
  raise typer.Exit(code=exit_code_for(e.code))
130
138
  except typer.Exit:
131
139
  raise
@@ -145,7 +153,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
145
153
  warnings=ctx.warnings,
146
154
  metrics=ctx.metrics,
147
155
  )
148
- emit_stdout(envelope.to_json_bytes())
156
+ emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
149
157
  raise typer.Exit(code=90)
150
158
  else:
151
159
  duration_ms = int((time.monotonic() - start) * 1000)
@@ -155,15 +163,20 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
155
163
  from confpub.config import load_config as _load_verbose_config
156
164
 
157
165
  diag: dict[str, Any] = {
166
+ "duration_ms": duration_ms,
158
167
  "command": command_name,
159
168
  "target": ctx.target,
160
169
  "warning_count": len(ctx.warnings),
161
170
  "python_version": sys.version,
162
171
  "confpub_version": __version__,
163
172
  }
173
+ if ctx.client and hasattr(ctx.client, "_call_count"):
174
+ diag["api_call_count"] = ctx.client._call_count
164
175
  try:
165
176
  _vcfg = _load_verbose_config()
177
+ diag["config_source"] = _vcfg.token_source
166
178
  diag["confluence_url"] = _vcfg.base_url
179
+ diag["is_cloud"] = _vcfg.is_cloud
167
180
  except Exception:
168
181
  pass
169
182
  ctx.metrics["diagnostics"] = diag
@@ -174,7 +187,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
174
187
  warnings=ctx.warnings,
175
188
  metrics=ctx.metrics,
176
189
  )
177
- emit_stdout(envelope.to_json_bytes())
190
+ emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
178
191
 
179
192
 
180
193
  # ---------------------------------------------------------------------------
@@ -192,8 +205,15 @@ def page_list(
192
205
  with command_context("page.list", target={"space": space}) as ctx:
193
206
  from confpub.confluence import build_client, _slim_page
194
207
  client = build_client()
195
- pages = client.list_pages(space, start=start, limit=limit)
196
- 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
+ }
197
217
 
198
218
 
199
219
  @page_app.command("inspect")
@@ -208,6 +228,7 @@ def page_inspect(
208
228
  with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
209
229
  from confpub.confluence import build_client, _slim_page
210
230
  client = build_client()
231
+ ctx.client = client
211
232
  if page_id:
212
233
  page = client.get_page_by_id(page_id)
213
234
  else:
@@ -240,6 +261,7 @@ def page_publish(
240
261
  space: str = typer.Option(..., "--space", help="Confluence space key"),
241
262
  parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
242
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"),
243
265
  page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
244
266
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
245
267
  backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
@@ -247,7 +269,7 @@ def page_publish(
247
269
  ) -> None:
248
270
  """Publish a single Markdown file to Confluence."""
249
271
  from confpub.publish import derive_title
250
- resolved_title = derive_title(file, title)
272
+ resolved_title = derive_title(file, title, title_from_h1=title_from_h1)
251
273
  target = {"space": space, "title": resolved_title, "file": file}
252
274
  if page_id:
253
275
  target["page_id"] = page_id
@@ -325,6 +347,7 @@ def page_delete(
325
347
  )
326
348
  from confpub.confluence import build_client
327
349
  client = build_client()
350
+ ctx.client = client
328
351
 
329
352
  # Collect descendant IDs before deleting (for lockfile cleanup)
330
353
  deleted_ids: set[str] = set()
@@ -379,6 +402,7 @@ def page_move(
379
402
  )
380
403
  from confpub.confluence import build_client
381
404
  client = build_client()
405
+ ctx.client = client
382
406
 
383
407
  if target_parent_id:
384
408
  # Use target_id directly — more reliable, no title resolution needed
@@ -400,6 +424,7 @@ def space_list() -> None:
400
424
  with command_context("space.list") as ctx:
401
425
  from confpub.confluence import build_client
402
426
  client = build_client()
427
+ ctx.client = client
403
428
  spaces = client.list_spaces()
404
429
  ctx.result = {"spaces": spaces}
405
430
 
@@ -412,6 +437,7 @@ def attachment_list(
412
437
  with command_context("attachment.list", target={"page_id": page_id}) as ctx:
413
438
  from confpub.confluence import build_client, _slim_attachment
414
439
  client = build_client()
440
+ ctx.client = client
415
441
  attachments = client.get_attachments(page_id)
416
442
  ctx.result = {"attachments": [_slim_attachment(a) for a in attachments]}
417
443
 
@@ -425,6 +451,7 @@ def attachment_upload(
425
451
  with command_context("attachment.upload", target={"page_id": page_id, "file": file}) as ctx:
426
452
  from confpub.confluence import build_client
427
453
  client = build_client()
454
+ ctx.client = client
428
455
  result = client.upload_attachment(page_id, file)
429
456
  ctx.result = result
430
457
 
@@ -537,6 +564,7 @@ def label_list(
537
564
  with command_context("label.list", target={"page_id": page_id}) as ctx:
538
565
  from confpub.confluence import build_client
539
566
  client = build_client()
567
+ ctx.client = client
540
568
  labels = client.get_labels(page_id)
541
569
  ctx.result = {"labels": labels, "count": len(labels)}
542
570
 
@@ -566,6 +594,7 @@ def label_add(
566
594
 
567
595
  from confpub.confluence import build_client
568
596
  client = build_client()
597
+ ctx.client = client
569
598
  results = client.set_labels(page_id, label)
570
599
  ctx.result = {"labels_added": label, "results": results}
571
600
 
@@ -579,6 +608,7 @@ def label_remove(
579
608
  with command_context("label.remove", target={"page_id": page_id}) as ctx:
580
609
  from confpub.confluence import build_client
581
610
  client = build_client()
611
+ ctx.client = client
582
612
  results = []
583
613
  for lbl in label:
584
614
  result = client.remove_label(page_id, lbl)
@@ -625,6 +655,7 @@ def comment_add(
625
655
 
626
656
  from confpub.confluence import build_client
627
657
  client = build_client()
658
+ ctx.client = client
628
659
  result = client.add_comment(page_id, storage_body)
629
660
  ctx.result = result
630
661
 
@@ -669,6 +700,7 @@ def search(
669
700
 
670
701
  from confpub.confluence import build_client
671
702
  client = build_client()
703
+ ctx.client = client
672
704
  result = client.search(
673
705
  effective_cql,
674
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"]:
@@ -441,10 +457,16 @@ class ConfluenceClient:
441
457
  self._call_count += 1
442
458
  try:
443
459
  result = self._api.set_page_label(page_id, lbl)
444
- if isinstance(result, dict):
460
+ if isinstance(result, dict) and result.get("name"):
445
461
  results.append(_slim_label(result))
446
462
  elif isinstance(result, list):
447
- results.extend(_slim_label(r) for r in result)
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})
448
470
  except Exception as exc:
449
471
  self._handle_error(exc, "set_labels")
450
472
  return results
@@ -498,11 +520,18 @@ class ConfluenceClient:
498
520
  target_id=target_id,
499
521
  position=position,
500
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)
501
530
  return {
502
531
  "moved": True,
503
532
  "page_id": page_id,
504
533
  "target_parent": target_title or target_id,
505
- "result": result if isinstance(result, dict) else {"raw": str(result)},
534
+ "page": page_data,
506
535
  }
507
536
  except Exception as exc:
508
537
  self._handle_error(exc, "move_page")
@@ -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()
@@ -93,6 +93,17 @@ def build_guide() -> dict[str, Any]:
93
93
  "mutates": False,
94
94
  "description": "List pages in a Confluence space",
95
95
  "flags": ["--space", "--limit", "--start"],
96
+ "result_schema": {
97
+ "pages": "list of slim page objects",
98
+ "start": "int — current offset",
99
+ "limit": "int — page size",
100
+ "size": "int — number of pages returned in this batch",
101
+ "has_more": "bool — true if more pages may be available (heuristic: size >= limit)",
102
+ },
103
+ "agent_hint": (
104
+ "Use --start and --limit for pagination: first call with --start 0 --limit 25, "
105
+ "then if has_more is true, call again with --start 25 --limit 25, and so on."
106
+ ),
96
107
  },
97
108
  "page.inspect": {
98
109
  "group": "read",
@@ -122,11 +133,13 @@ def build_guide() -> dict[str, Any]:
122
133
  "group": "write",
123
134
  "mutates": True,
124
135
  "description": "Publish a single Markdown file to Confluence",
125
- "flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup", "--label"],
136
+ "flags": ["--space", "--parent", "--title", "--title-from-h1", "--page-id", "--dry-run", "--backup", "--label"],
126
137
  "agent_hint": (
138
+ "Title precedence: explicit --title > --title-from-h1 (first H1 heading) > filename inference. "
127
139
  "When --title is omitted, the title is inferred from the filename: "
128
140
  "the stem is extracted, hyphens and underscores are replaced with spaces, "
129
141
  "and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'. "
142
+ "Use --title-from-h1 to extract the title from the first # heading in the file. "
130
143
  "Use --label to apply labels (repeatable): --label api --label docs."
131
144
  ),
132
145
  },
@@ -315,6 +328,7 @@ def build_guide() -> dict[str, Any]:
315
328
  "flags": {
316
329
  "--quiet": "Suppress progress output on stderr",
317
330
  "--verbose": "Include diagnostics in result",
331
+ "--compact": "Output single-line JSON (no indentation)",
318
332
  "--version": "Show version and exit (top-level only)",
319
333
  },
320
334
  "placement": [
@@ -16,6 +16,7 @@ import orjson
16
16
  # Module-level overrides set by the CLI layer
17
17
  _quiet: bool | None = None
18
18
  _verbose: bool | None = None
19
+ _compact: bool = False
19
20
 
20
21
 
21
22
  def set_quiet(value: bool) -> None:
@@ -28,6 +29,16 @@ def set_verbose(value: bool) -> None:
28
29
  _verbose = value
29
30
 
30
31
 
32
+ def set_compact(value: bool) -> None:
33
+ global _compact
34
+ _compact = value
35
+
36
+
37
+ def is_compact() -> bool:
38
+ """True when JSON output should be single-line (no indentation)."""
39
+ return _compact
40
+
41
+
31
42
  def is_llm_mode() -> bool:
32
43
  """True when LLM=true is set in the environment."""
33
44
  return os.environ.get("LLM", "").lower() == "true"
@@ -23,10 +23,19 @@ from confpub.errors import (
23
23
  from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
24
24
 
25
25
 
26
- def derive_title(file: str, title: str | None = None) -> str:
27
- """Derive page title from explicit title or filename."""
26
+ def derive_title(file: str, title: str | None = None, *, title_from_h1: bool = False) -> str:
27
+ """Derive page title from explicit title, H1 heading, or filename.
28
+
29
+ Precedence: explicit --title > --title-from-h1 > filename inference.
30
+ """
28
31
  if title:
29
32
  return title
33
+ if title_from_h1:
34
+ from confpub.converter import extract_h1_title
35
+ md_text = Path(file).read_text(encoding="utf-8")
36
+ h1 = extract_h1_title(md_text)
37
+ if h1:
38
+ return h1
30
39
  return Path(file).stem.replace("-", " ").replace("_", " ").title()
31
40
 
32
41
 
@@ -200,9 +200,10 @@ def _build_page_tree(
200
200
  parent_id = str(entry["parent_id"]) if entry["parent_id"] else None
201
201
  file_path = file_paths.get(pid, "")
202
202
 
203
+ rel = os.path.relpath(file_path, output_dir).replace("\\", "/") if file_path else ""
203
204
  node: dict[str, Any] = {
204
205
  "title": page.get("title", ""),
205
- "file": os.path.relpath(file_path, output_dir) if file_path else "",
206
+ "file": rel,
206
207
  "children": [],
207
208
  }
208
209
  if labels_map.get(pid):
@@ -317,7 +318,7 @@ def pull_pages(
317
318
  "labels": page_labels,
318
319
  })
319
320
 
320
- # Generate manifest if requested or recursive with multiple pages
321
+ # Generate manifest if explicitly requested
321
322
  manifest_file: str | None = None
322
323
  if generate_manifest:
323
324
  root_title = root_page.get("title", "")
@@ -31,7 +31,7 @@ classifiers = [
31
31
  "Typing :: Typed",
32
32
  ]
33
33
  dependencies = [
34
- "typer[all]>=0.9",
34
+ "typer>=0.9",
35
35
  "pydantic>=2.0",
36
36
  "orjson>=3.9",
37
37
  "markdown-it-py[linkify,plugins]>=3.0",
@@ -135,8 +135,37 @@ class TestListPages:
135
135
  {"id": "1", "title": "Page A"},
136
136
  {"id": "2", "title": "Page B"},
137
137
  ]
138
- pages = client.list_pages("DEV")
139
- assert len(pages) == 2
138
+ result = client.list_pages("DEV")
139
+ assert len(result["pages"]) == 2
140
+ assert result["size"] == 2
141
+ assert result["start"] == 0
142
+ assert result["limit"] == 25
143
+ assert result["has_more"] is False
144
+
145
+ def test_has_more_when_full_batch(self, client):
146
+ """has_more should be True when batch size equals limit."""
147
+ client._mock_api.get_all_pages_from_space.return_value = [
148
+ {"id": str(i), "title": f"Page {i}"} for i in range(5)
149
+ ]
150
+ result = client.list_pages("DEV", limit=5)
151
+ assert result["has_more"] is True
152
+ assert result["size"] == 5
153
+
154
+ def test_has_more_false_when_partial_batch(self, client):
155
+ """has_more should be False when fewer results than limit."""
156
+ client._mock_api.get_all_pages_from_space.return_value = [
157
+ {"id": "1", "title": "Only Page"},
158
+ ]
159
+ result = client.list_pages("DEV", limit=25)
160
+ assert result["has_more"] is False
161
+ assert result["size"] == 1
162
+
163
+ def test_pagination_params_passed_through(self, client):
164
+ """start and limit should be passed through to the result."""
165
+ client._mock_api.get_all_pages_from_space.return_value = []
166
+ result = client.list_pages("DEV", start=50, limit=10)
167
+ assert result["start"] == 50
168
+ assert result["limit"] == 10
140
169
 
141
170
 
142
171
  class TestAttachments:
@@ -152,6 +181,20 @@ class TestAttachments:
152
181
  result = client.upload_attachment("123", "/tmp/file.png")
153
182
  assert result["title"] == "file.png"
154
183
 
184
+ def test_upload_attachment_passes_content_type(self, client):
185
+ """Suggestion 3: upload_attachment should detect and pass content_type for known types."""
186
+ client._mock_api.attach_file.return_value = {"title": "readme.txt"}
187
+ client.upload_attachment("123", "/tmp/readme.txt")
188
+ call_kwargs = client._mock_api.attach_file.call_args
189
+ assert call_kwargs[1].get("content_type") == "text/plain"
190
+
191
+ def test_upload_attachment_png_content_type(self, client):
192
+ """MIME type detection for .png files."""
193
+ client._mock_api.attach_file.return_value = {"title": "image.png"}
194
+ client.upload_attachment("123", "/tmp/image.png")
195
+ call_kwargs = client._mock_api.attach_file.call_args
196
+ assert call_kwargs[1].get("content_type") == "image/png"
197
+
155
198
 
156
199
  class TestDownloadAttachment:
157
200
  def test_relative_url_uses_api_url(self, client, tmp_path):
@@ -256,6 +299,22 @@ class TestLabels:
256
299
  assert client._mock_api.set_page_label.call_count == 2
257
300
  assert len(results) == 2
258
301
 
302
+ def test_set_labels_empty_response_falls_back(self, client):
303
+ """When API returns empty/malformed dict, fall back to input label name."""
304
+ client._mock_api.set_page_label.return_value = {}
305
+ results = client.set_labels("123", ["my-label"])
306
+ assert len(results) == 1
307
+ assert results[0]["name"] == "my-label"
308
+ assert results[0]["prefix"] == "global"
309
+ assert results[0]["id"] is None
310
+
311
+ def test_set_labels_list_response_with_empty_entries(self, client):
312
+ """When API returns a list with empty entries, fall back to input label name."""
313
+ client._mock_api.set_page_label.return_value = [{}]
314
+ results = client.set_labels("123", ["tag1"])
315
+ assert len(results) == 1
316
+ assert results[0]["name"] == "tag1"
317
+
259
318
  def test_set_labels_call_count(self, client):
260
319
  """Each label should increment _call_count."""
261
320
  initial = client._call_count
@@ -319,6 +378,18 @@ class TestMovePage:
319
378
  "DEV", "123", target_title=None, target_id="456", position="append",
320
379
  )
321
380
 
381
+ def test_move_page_returns_normalized_page(self, client):
382
+ """Bug 3: move_page should return a 'page' key with slim page data, not raw 'result'."""
383
+ client._mock_api.move_page.return_value = {
384
+ "page": {"id": "123", "title": "My Page", "version": {"number": 3, "when": "2026-01-01"}}
385
+ }
386
+ result = client.move_page("DEV", "123", target_title="New Parent")
387
+ assert "result" not in result
388
+ assert "page" in result
389
+ assert result["page"]["id"] == "123"
390
+ assert result["page"]["title"] == "My Page"
391
+ assert result["moved"] is True
392
+
322
393
  def test_move_page_not_found(self, client):
323
394
  client._mock_api.move_page.side_effect = Exception("404 Not Found")
324
395
  with pytest.raises(ConfpubError) as exc_info:
@@ -348,6 +419,24 @@ class TestErrorTranslation:
348
419
  client.list_spaces()
349
420
  assert exc_info.value.code == ERR_IO_CONNECTION
350
421
 
422
+ def test_403_has_check_input_action(self, client):
423
+ """Bug 5: 403 errors should have suggested_action='check_input'."""
424
+ client._mock_api.get_all_spaces.side_effect = Exception("403 Forbidden")
425
+ with pytest.raises(ConfpubError) as exc_info:
426
+ client.list_spaces()
427
+ assert exc_info.value.code == ERR_AUTH_FORBIDDEN
428
+ assert exc_info.value.suggested_action == "check_input"
429
+
430
+ def test_permission_denied_has_check_input_action(self, client):
431
+ """Bug 5: permission-denied errors should have suggested_action='check_input'."""
432
+ client._mock_api.get_all_spaces.side_effect = Exception(
433
+ "User does not have permission to view the content"
434
+ )
435
+ with pytest.raises(ConfpubError) as exc_info:
436
+ client.list_spaces()
437
+ assert exc_info.value.code == ERR_AUTH_FORBIDDEN
438
+ assert exc_info.value.suggested_action == "check_input"
439
+
351
440
  def test_permission_error(self, client):
352
441
  client._mock_api.get_all_spaces.side_effect = Exception(
353
442
  "User does not have permission to view the content"
@@ -1,6 +1,6 @@
1
1
  """Tests for confpub.converter module."""
2
2
 
3
- from confpub.converter import convert_markdown, fingerprint_content
3
+ from confpub.converter import convert_markdown, extract_h1_title, fingerprint_content
4
4
 
5
5
 
6
6
  class TestHeadings:
@@ -220,3 +220,31 @@ def hello():
220
220
  assert "<blockquote>" in result
221
221
  assert "<hr />" in result
222
222
  assert '<a href="https://example.com">' in result
223
+
224
+
225
+ class TestExtractH1Title:
226
+ """Suggestion 2: extract_h1_title should extract text from first H1 heading."""
227
+
228
+ def test_simple_h1(self):
229
+ assert extract_h1_title("# My Page Title") == "My Page Title"
230
+
231
+ def test_h1_with_body(self):
232
+ md = "# Getting Started\n\nSome content here."
233
+ assert extract_h1_title(md) == "Getting Started"
234
+
235
+ def test_no_h1_returns_none(self):
236
+ assert extract_h1_title("## Only H2\n\nNo H1 here.") is None
237
+
238
+ def test_empty_returns_none(self):
239
+ assert extract_h1_title("") is None
240
+
241
+ def test_h1_with_inline_code(self):
242
+ assert extract_h1_title("# Using `confpub` CLI") == "Using confpub CLI"
243
+
244
+ def test_first_h1_wins(self):
245
+ md = "# First Title\n\n## Sub\n\n# Second Title"
246
+ assert extract_h1_title(md) == "First Title"
247
+
248
+ def test_h2_before_h1(self):
249
+ md = "## Intro\n\n# Main Title"
250
+ assert extract_h1_title(md) == "Main Title"
@@ -112,7 +112,7 @@ class TestPersonalSpaceKeyCLI:
112
112
 
113
113
  def fake_list_pages(self, space, **kwargs):
114
114
  captured["space"] = space
115
- return []
115
+ return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
116
116
 
117
117
  from confpub.confluence import ConfluenceClient
118
118
  monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
@@ -309,6 +309,132 @@ class TestPageMoveValidation:
309
309
  assert "space" in data["errors"][0]["message"].lower()
310
310
 
311
311
 
312
+ class TestCompactFlag:
313
+ """Suggestion 4: --compact should produce single-line JSON output."""
314
+
315
+ def test_compact_produces_single_line(self):
316
+ result = runner.invoke(app, ["--compact", "guide"])
317
+ assert result.exit_code == 0
318
+ # Compact output should be a single line (no newlines within the JSON)
319
+ lines = result.output.strip().split("\n")
320
+ assert len(lines) == 1
321
+ data = json.loads(lines[0])
322
+ assert data["ok"] is True
323
+
324
+ def test_compact_between_group_and_command(self, monkeypatch):
325
+ """--compact should work between group name and subcommand."""
326
+ def fake_list_pages(self, space, **kwargs):
327
+ return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
328
+
329
+ from confpub.confluence import ConfluenceClient
330
+ monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
331
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
332
+
333
+ result = runner.invoke(app, ["page", "--compact", "list", "--space", "DEV"])
334
+ assert result.exit_code == 0
335
+ lines = result.output.strip().split("\n")
336
+ assert len(lines) == 1
337
+
338
+ def test_without_compact_is_multiline(self):
339
+ result = runner.invoke(app, ["guide"])
340
+ assert result.exit_code == 0
341
+ lines = result.output.strip().split("\n")
342
+ assert len(lines) > 1
343
+
344
+
345
+ class TestVerboseDiagnostics:
346
+ """Bug 6: --verbose should include rich diagnostics."""
347
+
348
+ def test_verbose_includes_diagnostics(self):
349
+ result = runner.invoke(app, ["--verbose", "guide"])
350
+ assert result.exit_code == 0
351
+ data = json.loads(result.output)
352
+ assert "diagnostics" in data["metrics"]
353
+ diag = data["metrics"]["diagnostics"]
354
+ assert "duration_ms" in diag
355
+ assert "confpub_version" in diag
356
+ assert "command" in diag
357
+
358
+ def test_verbose_with_client_includes_api_call_count(self, monkeypatch):
359
+ """When a client is used, diagnostics should include api_call_count."""
360
+ def fake_list_pages(self, space, **kwargs):
361
+ self._call_count += 1
362
+ return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
363
+
364
+ from confpub.confluence import ConfluenceClient
365
+ from confpub.config import ResolvedConfig
366
+ from unittest.mock import patch, MagicMock
367
+
368
+ mock_config = ResolvedConfig(
369
+ base_url="https://test.atlassian.net/wiki",
370
+ user="user@test.com",
371
+ token="test_token",
372
+ token_source="env_var",
373
+ )
374
+
375
+ with patch("confpub.confluence.ConfluenceClient._build_api") as mock_build:
376
+ mock_build.return_value = MagicMock()
377
+ real_client = ConfluenceClient(mock_config)
378
+
379
+ monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
380
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: real_client)
381
+
382
+ result = runner.invoke(app, ["page", "--verbose", "list", "--space", "DEV"])
383
+ assert result.exit_code == 0
384
+ data = json.loads(result.output)
385
+ assert "diagnostics" in data["metrics"]
386
+ diag = data["metrics"]["diagnostics"]
387
+ assert "api_call_count" in diag
388
+ assert diag["api_call_count"] >= 1
389
+
390
+
391
+ class TestPageListPagination:
392
+ """Suggestion 1: page.list should return pagination metadata."""
393
+
394
+ def test_page_list_has_pagination_fields(self, monkeypatch):
395
+ def fake_list_pages(self, space, **kwargs):
396
+ return {
397
+ "pages": [{"id": "1", "title": "P1"}],
398
+ "start": 0, "limit": 25, "size": 1, "has_more": False,
399
+ }
400
+
401
+ from confpub.confluence import ConfluenceClient
402
+ from confpub.config import ResolvedConfig
403
+ from unittest.mock import patch, MagicMock
404
+
405
+ mock_config = ResolvedConfig(
406
+ base_url="https://test.atlassian.net/wiki",
407
+ user="user@test.com",
408
+ token="test_token",
409
+ token_source="env_var",
410
+ )
411
+ with patch("confpub.confluence.ConfluenceClient._build_api") as mock_build:
412
+ mock_build.return_value = MagicMock()
413
+ real_client = ConfluenceClient(mock_config)
414
+
415
+ monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
416
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: real_client)
417
+
418
+ result = runner.invoke(app, ["page", "list", "--space", "DEV"])
419
+ assert result.exit_code == 0
420
+ data = json.loads(result.output)
421
+ r = data["result"]
422
+ assert "pages" in r
423
+ assert "start" in r
424
+ assert "limit" in r
425
+ assert "size" in r
426
+ assert "has_more" in r
427
+
428
+
429
+ class TestTitleFromH1Flag:
430
+ """Suggestion 2: --title-from-h1 flag should be available on page publish."""
431
+
432
+ def test_title_from_h1_in_help(self):
433
+ result = runner.invoke(app, ["page", "publish", "--help"])
434
+ assert result.exit_code == 0
435
+ assert "--title-from-h1" in result.output
436
+
437
+
312
438
  class TestEnvelopeContract:
313
439
  def test_guide_returns_full_envelope(self):
314
440
  result = runner.invoke(app, ["guide"])
@@ -8,9 +8,11 @@ from confpub.output import (
8
8
  emit_stderr,
9
9
  emit_stdout,
10
10
  is_ci,
11
+ is_compact,
11
12
  is_llm_mode,
12
13
  is_quiet,
13
14
  is_verbose,
15
+ set_compact,
14
16
  set_quiet,
15
17
  set_verbose,
16
18
  )
@@ -97,3 +99,17 @@ class TestEmitStderr:
97
99
  captured = capsys.readouterr()
98
100
  assert captured.err == ""
99
101
  set_quiet(None) # type: ignore[arg-type]
102
+
103
+
104
+ class TestCompactMode:
105
+ """Suggestion 4: compact mode plumbing."""
106
+
107
+ def test_set_compact(self):
108
+ set_compact(True)
109
+ assert is_compact() is True
110
+ set_compact(False)
111
+ assert is_compact() is False
112
+
113
+ def test_default_is_not_compact(self):
114
+ set_compact(False)
115
+ assert is_compact() is False
@@ -46,6 +46,24 @@ class TestDeriveTitle:
46
46
  def test_path_uses_stem_only(self):
47
47
  assert derive_title("docs/subfolder/overview.md") == "Overview"
48
48
 
49
+ def test_title_from_h1(self, tmp_path):
50
+ """Suggestion 2: derive_title with title_from_h1=True extracts H1."""
51
+ md_file = tmp_path / "test.md"
52
+ md_file.write_text("# My Custom Title\n\nContent here.")
53
+ assert derive_title(str(md_file), title_from_h1=True) == "My Custom Title"
54
+
55
+ def test_title_from_h1_falls_back_to_filename(self, tmp_path):
56
+ """When no H1 is found, fall back to filename inference."""
57
+ md_file = tmp_path / "api-docs.md"
58
+ md_file.write_text("## Only H2\n\nNo H1 here.")
59
+ assert derive_title(str(md_file), title_from_h1=True) == "Api Docs"
60
+
61
+ def test_explicit_title_beats_h1(self, tmp_path):
62
+ """Explicit --title should win over --title-from-h1."""
63
+ md_file = tmp_path / "test.md"
64
+ md_file.write_text("# H1 Title\n\nContent here.")
65
+ assert derive_title(str(md_file), "Explicit Title", title_from_h1=True) == "Explicit Title"
66
+
49
67
 
50
68
  class TestPublishDryRun:
51
69
  @patch("confpub.publish.load_config")
@@ -511,6 +511,56 @@ class TestMultiLevelRecursivePull:
511
511
  # ---------------------------------------------------------------------------
512
512
 
513
513
 
514
+ class TestManifestPathSeparators:
515
+ """Bug 1: Manifest file paths must use forward slashes on all platforms."""
516
+
517
+ def test_manifest_paths_no_backslashes(self, tmp_path):
518
+ root = _make_page("1", "Root")
519
+ child = _make_page("2", "Child")
520
+ pages = {"1": root, "2": child}
521
+ children = {"1": [child]}
522
+ client = _mock_client(pages, children)
523
+
524
+ with patch("confpub.puller.build_client", return_value=client):
525
+ result = pull_pages(
526
+ page_id="1",
527
+ output_dir=str(tmp_path),
528
+ recursive=True,
529
+ layout="nested",
530
+ generate_manifest=True,
531
+ )
532
+
533
+ manifest_content = Path(result["manifest_file"]).read_text()
534
+ # No backslashes should appear in any file paths in the manifest
535
+ for line in manifest_content.split("\n"):
536
+ if "file:" in line:
537
+ assert "\\" not in line, f"Backslash found in manifest path: {line}"
538
+
539
+
540
+ class TestNestedLayoutNoAutoManifest:
541
+ """Bug 4: --layout nested without --manifest should NOT create confpub.yaml."""
542
+
543
+ def test_nested_without_manifest_flag(self, tmp_path):
544
+ root = _make_page("1", "Root")
545
+ child = _make_page("2", "Child")
546
+ pages = {"1": root, "2": child}
547
+ children = {"1": [child]}
548
+ client = _mock_client(pages, children)
549
+
550
+ with patch("confpub.puller.build_client", return_value=client):
551
+ result = pull_pages(
552
+ page_id="1",
553
+ output_dir=str(tmp_path),
554
+ recursive=True,
555
+ layout="nested",
556
+ generate_manifest=False,
557
+ )
558
+
559
+ assert result["summary"]["manifest_generated"] is False
560
+ assert result["manifest_file"] is None
561
+ assert not (tmp_path / "confpub.yaml").exists()
562
+
563
+
514
564
  class TestManifestFlag:
515
565
  def test_single_page_with_manifest_flag(self, tmp_path):
516
566
  """--manifest generates confpub.yaml even for a single page."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes