confpub-cli 0.8.0__tar.gz → 1.1.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/CLAUDE.md +56 -0
  2. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/PKG-INFO +1 -1
  3. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/__init__.py +1 -1
  4. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/applier.py +5 -2
  5. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/cli.py +46 -7
  6. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/confluence.py +14 -2
  7. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/guide.py +76 -3
  8. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/lockfile.py +30 -1
  9. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/publish.py +1 -2
  10. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/puller.py +3 -3
  11. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_applier.py +57 -0
  12. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_guide.py +61 -0
  13. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_integration.py +46 -1
  14. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_lockfile.py +33 -0
  15. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/.github/workflows/publish.yml +0 -0
  16. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/.gitignore +0 -0
  17. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/LICENSE +0 -0
  18. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/PRD.md +0 -0
  19. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/README.md +0 -0
  20. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/assets.py +0 -0
  21. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/config.py +0 -0
  22. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/converter.py +0 -0
  23. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/envelope.py +0 -0
  24. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/errors.py +0 -0
  25. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/manifest.py +0 -0
  26. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/output.py +0 -0
  27. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/planner.py +0 -0
  28. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/py.typed +0 -0
  29. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/reverse_converter.py +0 -0
  30. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/validator.py +0 -0
  31. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub/verifier.py +0 -0
  32. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/confpub.lock +0 -0
  33. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/pyproject.toml +0 -0
  34. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/__init__.py +0 -0
  35. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/conftest.py +0 -0
  36. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_assets.py +0 -0
  37. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_config.py +0 -0
  38. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_confluence.py +0 -0
  39. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_converter.py +0 -0
  40. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_envelope.py +0 -0
  41. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_errors.py +0 -0
  42. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_manifest.py +0 -0
  43. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_output.py +0 -0
  44. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_planner.py +0 -0
  45. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_publish.py +0 -0
  46. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_puller.py +0 -0
  47. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_reverse_converter.py +0 -0
  48. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_validator.py +0 -0
  49. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/tests/test_verifier.py +0 -0
  50. {confpub_cli-0.8.0 → confpub_cli-1.1.0}/uv.lock +0 -0
@@ -0,0 +1,56 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Build & Test Commands
6
+
7
+ ```bash
8
+ uv pip install -e ".[dev]" # Install with dev deps
9
+ pytest tests/ -v # Run all tests
10
+ pytest tests/test_converter.py -v # Run one test module
11
+ pytest tests/test_converter.py::TestHeadings::test_h1 -v # Run single test
12
+ pytest tests/ -k "fingerprint" -v # Run tests matching name pattern
13
+ pytest tests/ -v --cov=confpub # Run with coverage
14
+ uvx hatch version minor # Bump version (patch/minor/major)
15
+ ```
16
+
17
+ No linter is configured. Python 3.10+ required.
18
+
19
+ ## Architecture
20
+
21
+ Agent-first CLI for publishing Markdown to Confluence. Every command returns structured JSON on stdout; stderr is reserved for progress/diagnostics.
22
+
23
+ ### Command flow
24
+
25
+ All commands follow the same pattern through `cli.py`:
26
+
27
+ 1. Typer handler receives flags
28
+ 2. `command_context()` context manager wraps execution — catches `ConfpubError` and unexpected exceptions, records timing, emits the JSON envelope via `Envelope` (envelope.py)
29
+ 3. Handler delegates to a domain module (publish.py, applier.py, puller.py, etc.)
30
+ 4. Domain module calls `ConfluenceClient` (confluence.py) which wraps atlassian-python-api and translates its exceptions into `ConfpubError`
31
+
32
+ ### Key design rules
33
+
34
+ - **stdout is JSON-only** — one envelope object per invocation, never mixed with text
35
+ - **Error codes are stable** — prefixed by category (`ERR_VALIDATION_*`, `ERR_AUTH_*`, `ERR_CONFLICT_*`, `ERR_IO_*`, `ERR_INTERNAL_*`), each maps to a fixed exit code via prefix (10/20/40/50/90). Never rename or remove error codes without a major version bump.
36
+ - **`ConfpubError`** carries code, message, retryable flag, suggested_action, and details dict — all fields flow into the envelope's `errors` array
37
+ - **Lockfile** (`confpub.lock`) maps page titles to Confluence page IDs for idempotent re-publishing. Updated by publish, pull, apply, and delete. Uses atomic writes (tempfile + rename).
38
+
39
+ ### Transactional workflow
40
+
41
+ `plan create` → `plan validate` → `plan apply` → `plan verify` — each step is a separate command with no side effects except apply. Fingerprinting (SHA-256 of storage-format body) detects external edits between plan creation and apply.
42
+
43
+ ### Markdown conversion
44
+
45
+ `converter.py` uses markdown-it-py with a custom `ConfluenceRenderer` to produce Confluence Storage Format XHTML. `reverse_converter.py` does the inverse using BeautifulSoup4 + markdownify. Both are pure functions (no I/O, no network).
46
+
47
+ ### Test conventions
48
+
49
+ - Tests use `typer.testing.CliRunner` via `run_cli` fixture (conftest.py)
50
+ - Confluence API calls are mocked with `unittest.mock` — no live integration tests
51
+ - Test classes group related cases (e.g., `TestHeadings`, `TestApplyPlanReal`)
52
+ - New domain functions get unit tests; CLI-level behavior gets integration tests in `test_integration.py`
53
+
54
+ ## Version
55
+
56
+ Defined in `confpub/__init__.py`, read by hatch from `pyproject.toml` config. Bump with `uvx hatch version`. Push to `main` triggers GitHub Actions publish to PyPI.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 0.8.0
3
+ Version: 1.1.0
4
4
  Summary: Agent-first CLI to publish Markdown to Confluence
5
5
  Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
6
6
  Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "1.1.0"
@@ -14,7 +14,7 @@ from typing import Any
14
14
  from confpub.assets import AssetRef, discover_assets, rewrite_image_urls, upload_assets
15
15
  from confpub.config import load_config
16
16
  from confpub.confluence import ConfluenceClient
17
- from confpub.converter import convert_markdown
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
20
20
  from confpub.manifest import PlanArtifact
@@ -125,7 +125,7 @@ def apply_plan(
125
125
  counts["attachments_upload"] += len(assets)
126
126
 
127
127
  # Update lockfile and parent tracking
128
- update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1)
128
+ update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1, content_fingerprint=fingerprint_content(storage))
129
129
  parent_ids[page.title] = new_id
130
130
 
131
131
  counts["create"] += 1
@@ -179,6 +179,7 @@ def apply_plan(
179
179
  update_lockfile(
180
180
  lockfile, page.title, page.confluence_page_id,
181
181
  new_version if isinstance(new_version, int) else 1,
182
+ content_fingerprint=fingerprint_content(storage),
182
183
  )
183
184
  parent_ids[page.title] = page.confluence_page_id
184
185
 
@@ -193,4 +194,6 @@ def apply_plan(
193
194
  "dry_run": dry_run,
194
195
  "changes": changes,
195
196
  "summary": counts,
197
+ "lockfile_updated": not dry_run and len(changes) > 0,
198
+ "lockfile_path": str(lockfile_path) if not dry_run else None,
196
199
  }
@@ -22,12 +22,22 @@ from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_
22
22
  # Subcommand group apps
23
23
  # ---------------------------------------------------------------------------
24
24
 
25
- page_app = typer.Typer(help="Page operations")
26
- plan_app = typer.Typer(help="Transactional plan workflow")
27
- auth_app = typer.Typer(help="Authentication")
28
- config_app = typer.Typer(help="Configuration")
29
- space_app = typer.Typer(help="Space operations")
30
- attachment_app = typer.Typer(help="Attachment operations")
25
+
26
+ def _group_callback(
27
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
28
+ verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
29
+ ) -> None:
30
+ """Allow --quiet/--verbose between the group name and the subcommand."""
31
+ set_quiet(quiet)
32
+ set_verbose(verbose)
33
+
34
+
35
+ page_app = typer.Typer(help="Page operations", callback=_group_callback)
36
+ plan_app = typer.Typer(help="Transactional plan workflow", callback=_group_callback)
37
+ auth_app = typer.Typer(help="Authentication", callback=_group_callback)
38
+ config_app = typer.Typer(help="Configuration", callback=_group_callback)
39
+ space_app = typer.Typer(help="Space operations", callback=_group_callback)
40
+ attachment_app = typer.Typer(help="Attachment operations", callback=_group_callback)
31
41
 
32
42
  # ---------------------------------------------------------------------------
33
43
  # Main app
@@ -171,12 +181,14 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
171
181
  @page_app.command("list")
172
182
  def page_list(
173
183
  space: str = typer.Option(..., "--space", help="Confluence space key"),
184
+ limit: int = typer.Option(25, "--limit", help="Maximum number of pages to return"),
185
+ start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
174
186
  ) -> None:
175
187
  """List pages in a Confluence space."""
176
188
  with command_context("page.list", target={"space": space}) as ctx:
177
189
  from confpub.confluence import build_client, _slim_page
178
190
  client = build_client()
179
- pages = client.list_pages(space)
191
+ pages = client.list_pages(space, start=start, limit=limit)
180
192
  ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/")) for p in pages]}
181
193
 
182
194
 
@@ -307,12 +319,35 @@ def page_delete(
307
319
  )
308
320
  from confpub.confluence import build_client
309
321
  client = build_client()
322
+
323
+ # Collect descendant IDs before deleting (for lockfile cleanup)
324
+ deleted_ids: set[str] = set()
310
325
  if page_id:
311
326
  if cascade:
327
+ deleted_ids.update(client.get_descendant_ids(page_id))
312
328
  client._delete_descendants(page_id)
329
+ deleted_ids.add(page_id)
313
330
  result = client.delete_page(page_id)
314
331
  else:
332
+ if cascade:
333
+ page = client.get_page(space, title)
334
+ if page:
335
+ pid = str(page["id"])
336
+ deleted_ids.update(client.get_descendant_ids(pid))
337
+ deleted_ids.add(pid)
315
338
  result = client.delete_page_by_title(space, title, cascade=cascade)
339
+
340
+ # Clean up lockfile entries for deleted pages
341
+ from pathlib import Path
342
+ from confpub.lockfile import load_lockfile, save_lockfile, remove_by_page_ids
343
+ lockfile_path = Path.cwd() / "confpub.lock"
344
+ lockfile = load_lockfile(lockfile_path)
345
+ if lockfile and remove_by_page_ids(lockfile, deleted_ids, title=title if not page_id else None):
346
+ save_lockfile(lockfile_path, lockfile)
347
+
348
+ # Enrich result with deleted ID summary
349
+ result["deleted_ids"] = sorted(deleted_ids)
350
+ result["deleted_count"] = len(deleted_ids)
316
351
  ctx.result = result
317
352
 
318
353
 
@@ -494,6 +529,10 @@ def search(
494
529
  excerpt_length=excerpt_length,
495
530
  )
496
531
  result["cql_query"] = effective_cql
532
+ if space and result.get("total", 0) == 0:
533
+ ctx.warnings.append(
534
+ f"No results found. Verify space '{space}' exists (use 'space list' to check)."
535
+ )
497
536
  ctx.result = result
498
537
 
499
538
 
@@ -187,6 +187,16 @@ class ConfluenceClient:
187
187
  self._delete_descendants(page_id)
188
188
  return self.delete_page(page_id)
189
189
 
190
+ def get_descendant_ids(self, page_id: str) -> set[str]:
191
+ """Recursively collect all descendant page IDs."""
192
+ ids: set[str] = set()
193
+ children = self.get_page_children(page_id)
194
+ for child in children:
195
+ child_id = str(child["id"])
196
+ ids.add(child_id)
197
+ ids.update(self.get_descendant_ids(child_id))
198
+ return ids
199
+
190
200
  def _delete_descendants(self, page_id: str) -> None:
191
201
  """Recursively delete all descendants depth-first (leaves first)."""
192
202
  children = self.get_page_children(page_id)
@@ -262,11 +272,13 @@ class ConfluenceClient:
262
272
  self._handle_error(exc, "list_spaces")
263
273
  return []
264
274
 
265
- def list_pages(self, space: str) -> list[dict[str, Any]]:
275
+ def list_pages(self, space: str, *, start: int = 0, limit: int = 25) -> list[dict[str, Any]]:
266
276
  """List pages in a space."""
267
277
  self._call_count += 1
268
278
  try:
269
- result = self._api.get_all_pages_from_space(space, expand="version")
279
+ result = self._api.get_all_pages_from_space(
280
+ space, start=start, limit=limit, expand="version",
281
+ )
270
282
  return result if isinstance(result, list) else []
271
283
  except Exception as exc:
272
284
  self._handle_error(exc, "list_pages")
@@ -66,7 +66,11 @@ def build_guide() -> dict[str, Any]:
66
66
  "mutates": False,
67
67
  "description": "Search Confluence content using CQL",
68
68
  "flags": ["--cql", "--space", "--title", "--type", "--limit", "--start", "--include-archived", "--excerpt-length"],
69
- "agent_hint": "Most agent workflows should include --type page to exclude attachments and space entities from results.",
69
+ "agent_hint": (
70
+ "Most agent workflows should include --type page to exclude attachments and space entities from results. "
71
+ "Use --start and --limit for pagination: first call with --start 0 --limit 25, "
72
+ "then if has_more is true, call again with --start 25 --limit 25, and so on."
73
+ ),
70
74
  "result_schema": {
71
75
  "cql_query": "string — effective CQL sent to the API",
72
76
  "results": "list of {id, type, title, excerpt, url, space_key, entity_type, status, last_modified, container_title}",
@@ -80,25 +84,49 @@ def build_guide() -> dict[str, Any]:
80
84
  "confpub search --space DEV --type page --limit 10",
81
85
  'confpub search --space DEV --cql \'title ~ "deploy"\'',
82
86
  'confpub search --title "deploy guide" --space DEV',
87
+ "confpub search --space DEV --type page --start 0 --limit 50",
83
88
  ],
84
89
  },
85
90
  "page.list": {
86
91
  "group": "read",
87
92
  "mutates": False,
88
93
  "description": "List pages in a Confluence space",
89
- "flags": ["--space"],
94
+ "flags": ["--space", "--limit", "--start"],
90
95
  },
91
96
  "page.inspect": {
92
97
  "group": "read",
93
98
  "mutates": False,
94
99
  "description": "Inspect a Confluence page",
95
- "flags": ["--space", "--title", "--page-id", "--format"],
100
+ "flags": ["--space", "--title", "--page-id", "--format", "--raw"],
101
+ "agent_hint": (
102
+ "Use --format markdown to get the page body as Markdown instead of Confluence storage format. "
103
+ "Use --raw for the full unprocessed API response (useful for debugging)."
104
+ ),
105
+ "result_schema": {
106
+ "page_id": "string",
107
+ "title": "string",
108
+ "space_key": "string",
109
+ "version": "int",
110
+ "url": "string",
111
+ "body_storage": "string (when --format storage, the default)",
112
+ "body_markdown": "string (when --format markdown)",
113
+ },
114
+ "examples": [
115
+ 'confpub page inspect --space DEV --title "My Page"',
116
+ "confpub page inspect --page-id 12345 --format markdown",
117
+ "confpub page inspect --page-id 12345 --raw",
118
+ ],
96
119
  },
97
120
  "page.publish": {
98
121
  "group": "write",
99
122
  "mutates": True,
100
123
  "description": "Publish a single Markdown file to Confluence",
101
124
  "flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
125
+ "agent_hint": (
126
+ "When --title is omitted, the title is inferred from the filename: "
127
+ "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
+ ),
102
130
  },
103
131
  "page.pull": {
104
132
  "group": "read",
@@ -112,6 +140,11 @@ def build_guide() -> dict[str, Any]:
112
140
  "safety_flags": {
113
141
  "--force": "Overwrites existing local files without confirmation",
114
142
  },
143
+ "agent_hint": (
144
+ "During recursive pulls, NDJSON progress events are emitted on stderr "
145
+ "with step (running discovery count) and total (0 until known). "
146
+ "Use --quiet to suppress."
147
+ ),
115
148
  },
116
149
  "page.delete": {
117
150
  "group": "write",
@@ -121,6 +154,10 @@ def build_guide() -> dict[str, Any]:
121
154
  "safety_flags": {
122
155
  "--cascade": "Also deletes child pages",
123
156
  },
157
+ "result_schema": {
158
+ "deleted_ids": "list of string — sorted page IDs that were deleted",
159
+ "deleted_count": "int — number of pages deleted (including children when --cascade)",
160
+ },
124
161
  },
125
162
  "space.list": {
126
163
  "group": "read",
@@ -167,6 +204,13 @@ def build_guide() -> dict[str, Any]:
167
204
  ),
168
205
  "--cascade": "Allows deletes that affect child pages",
169
206
  },
207
+ "result_schema": {
208
+ "dry_run": "bool",
209
+ "changes": "list of change records",
210
+ "summary": "{create: int, update: int, attachments_upload: int}",
211
+ "lockfile_updated": "bool — true if lockfile was written",
212
+ "lockfile_path": "string | null — absolute path to lockfile (null on dry-run)",
213
+ },
170
214
  },
171
215
  "plan.verify": {
172
216
  "group": "transactional",
@@ -185,6 +229,16 @@ def build_guide() -> dict[str, Any]:
185
229
  "mutates": True,
186
230
  "description": "Set a configuration value",
187
231
  "flags": [],
232
+ "args": ["KEY", "VALUE"],
233
+ "agent_hint": (
234
+ "Valid keys: base_url, user, token. "
235
+ "Values are persisted to the config file (~/.confpub/config.json)."
236
+ ),
237
+ "examples": [
238
+ "confpub config set base_url https://mysite.atlassian.net/wiki",
239
+ "confpub config set user alice@example.com",
240
+ "confpub config set token ATATT...",
241
+ ],
188
242
  },
189
243
  "config.inspect": {
190
244
  "group": "config",
@@ -216,6 +270,19 @@ def build_guide() -> dict[str, Any]:
216
270
  ERR_INTERNAL_REVERSE_CONVERTER: _error_code_entry(ERR_INTERNAL_REVERSE_CONVERTER),
217
271
  ERR_INTERNAL_SDK: _error_code_entry(ERR_INTERNAL_SDK),
218
272
  },
273
+ "global_flags": {
274
+ "description": "Flags that can be placed at the top level or between group name and subcommand.",
275
+ "flags": {
276
+ "--quiet": "Suppress progress output on stderr",
277
+ "--verbose": "Include diagnostics in result",
278
+ "--version": "Show version and exit (top-level only)",
279
+ },
280
+ "placement": [
281
+ "confpub --quiet page publish ... (before the group)",
282
+ "confpub page --quiet publish ... (between group and command)",
283
+ "Both positions are equivalent; the flag is parsed by the group callback",
284
+ ],
285
+ },
219
286
  "concurrency": {
220
287
  "rule": (
221
288
  "Never run multiple write commands against the same "
@@ -244,9 +311,15 @@ def build_guide() -> dict[str, Any]:
244
311
  },
245
312
  "behavior": [
246
313
  "Created/updated automatically by page.publish, page.pull, and plan.apply",
314
+ "Entries removed automatically by page.delete (including --cascade)",
247
315
  "Written atomically (temp file + rename) for crash safety",
248
316
  "Used by plan.create to detect existing pages and versions",
249
317
  "Does not prevent concurrent operations — purely local state tracking",
318
+ (
319
+ "Path resolution: page.publish and page.delete use CWD/confpub.lock; "
320
+ "page.pull uses <output-dir>/confpub.lock; "
321
+ "plan.apply uses <plan-dir>/confpub.lock (same directory as the plan artifact)"
322
+ ),
250
323
  ],
251
324
  },
252
325
  "assertions": {
@@ -86,7 +86,36 @@ def save_lockfile(path: str | Path, lockfile: Lockfile) -> None:
86
86
 
87
87
  def update_lockfile(
88
88
  lockfile: Lockfile, title: str, page_id: str, version: int,
89
+ content_fingerprint: str | None = None,
89
90
  ) -> Lockfile:
90
91
  """Update a page entry in the lockfile."""
91
- lockfile.pages[title] = LockPageEntry(page_id=page_id, version=version)
92
+ lockfile.pages[title] = LockPageEntry(
93
+ page_id=page_id, version=version, content_fingerprint=content_fingerprint,
94
+ )
92
95
  return lockfile
96
+
97
+
98
+ def remove_from_lockfile(lockfile: Lockfile, title: str) -> bool:
99
+ """Remove a page entry from the lockfile by title.
100
+
101
+ Returns True if the entry was found and removed, False otherwise.
102
+ """
103
+ if title in lockfile.pages:
104
+ del lockfile.pages[title]
105
+ return True
106
+ return False
107
+
108
+
109
+ def remove_by_page_ids(lockfile: Lockfile, page_ids: set[str], title: str | None = None) -> bool:
110
+ """Remove lockfile entries matching any of the given page IDs, plus an optional title.
111
+
112
+ Returns True if any entries were removed.
113
+ """
114
+ removed = False
115
+ if title:
116
+ removed = remove_from_lockfile(lockfile, title) or removed
117
+ for lf_title, entry in list(lockfile.pages.items()):
118
+ if entry.page_id in page_ids:
119
+ del lockfile.pages[lf_title]
120
+ removed = True
121
+ return removed
@@ -194,8 +194,7 @@ def publish_page(
194
194
 
195
195
  # Update lockfile
196
196
  new_version_int = new_version if isinstance(new_version, int) else 1
197
- update_lockfile(lockfile, page_title, page_id, new_version_int)
198
- lockfile.pages[page_title].content_fingerprint = local_fingerprint
197
+ update_lockfile(lockfile, page_title, page_id, new_version_int, content_fingerprint=local_fingerprint)
199
198
  save_lockfile(lockfile_path, lockfile)
200
199
 
201
200
  change = {
@@ -47,7 +47,7 @@ def _collect_tree(
47
47
  def _walk(pid: str, parent_id: str | None) -> None:
48
48
  children = client.get_page_children_deep(pid)
49
49
  if children:
50
- emit_progress(0, 0, f"Found {len(children)} child page(s) under {pid}")
50
+ emit_progress(len(pages) + 1, 0, f"Found {len(children)} child page(s) under {pid}")
51
51
  for child in children:
52
52
  child_id = str(child["id"])
53
53
  pages.append({
@@ -130,8 +130,8 @@ def _check_conflicts(file_paths: dict[str, str], force: bool) -> None:
130
130
  ERR_CONFLICT_FILE_EXISTS,
131
131
  f"Output files already exist: {', '.join(existing[:5])}"
132
132
  + (f" (and {len(existing) - 5} more)" if len(existing) > 5 else ""),
133
- details={"existing_files": existing},
134
- suggested_action="fix_input",
133
+ details={"existing_files": existing, "hint": "Use --force to overwrite existing files"},
134
+ suggested_action="retry_with_flag",
135
135
  )
136
136
 
137
137
 
@@ -127,6 +127,63 @@ class TestApplyPlanReal:
127
127
  assert "Existing Page" in lock_data["pages"]
128
128
 
129
129
 
130
+ class TestApplyLockfileFingerprints:
131
+ @patch("confpub.applier.load_config")
132
+ @patch("confpub.applier.ConfluenceClient")
133
+ def test_lockfile_entries_have_fingerprints(self, MockClient, mock_config, plan_dir, mock_client):
134
+ MockClient.return_value = mock_client
135
+ mock_config.return_value = MagicMock()
136
+
137
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=False)
138
+
139
+ lockfile_path = plan_dir / "confpub.lock"
140
+ assert lockfile_path.exists()
141
+ lock_data = json.loads(lockfile_path.read_text())
142
+ for title in ("New Page", "Existing Page"):
143
+ entry = lock_data["pages"][title]
144
+ assert entry["content_fingerprint"] is not None, f"{title} has null fingerprint"
145
+ assert len(entry["content_fingerprint"]) == 64 # SHA-256 hex digest
146
+
147
+ @patch("confpub.applier.load_config")
148
+ @patch("confpub.applier.ConfluenceClient")
149
+ def test_lockfile_updated_in_result(self, MockClient, mock_config, plan_dir, mock_client):
150
+ MockClient.return_value = mock_client
151
+ mock_config.return_value = MagicMock()
152
+
153
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=False)
154
+ assert result["lockfile_updated"] is True
155
+
156
+ @patch("confpub.applier.load_config")
157
+ @patch("confpub.applier.ConfluenceClient")
158
+ def test_lockfile_updated_false_on_dry_run(self, MockClient, mock_config, plan_dir, mock_client):
159
+ MockClient.return_value = mock_client
160
+ mock_config.return_value = MagicMock()
161
+
162
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=True)
163
+ assert result["lockfile_updated"] is False
164
+
165
+
166
+ class TestApplyLockfilePath:
167
+ @patch("confpub.applier.load_config")
168
+ @patch("confpub.applier.ConfluenceClient")
169
+ def test_lockfile_path_present_on_real_apply(self, MockClient, mock_config, plan_dir, mock_client):
170
+ MockClient.return_value = mock_client
171
+ mock_config.return_value = MagicMock()
172
+
173
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=False)
174
+ assert result["lockfile_path"] is not None
175
+ assert "confpub.lock" in result["lockfile_path"]
176
+
177
+ @patch("confpub.applier.load_config")
178
+ @patch("confpub.applier.ConfluenceClient")
179
+ def test_lockfile_path_null_on_dry_run(self, MockClient, mock_config, plan_dir, mock_client):
180
+ MockClient.return_value = mock_client
181
+ mock_config.return_value = MagicMock()
182
+
183
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=True)
184
+ assert result["lockfile_path"] is None
185
+
186
+
130
187
  class TestFingerprintCheck:
131
188
  @patch("confpub.applier.load_config")
132
189
  @patch("confpub.applier.ConfluenceClient")
@@ -112,3 +112,64 @@ class TestBuildGuide:
112
112
  assert "rule" in concurrency
113
113
  assert "safe_patterns" in concurrency
114
114
  assert len(concurrency["safe_patterns"]) > 0
115
+
116
+ def test_global_flags_section(self):
117
+ guide = build_guide()
118
+ assert "global_flags" in guide
119
+ gf = guide["global_flags"]
120
+ assert "--quiet" in gf["flags"]
121
+ assert "--verbose" in gf["flags"]
122
+ assert "placement" in gf
123
+ assert len(gf["placement"]) > 0
124
+
125
+ def test_page_inspect_has_raw_flag(self):
126
+ guide = build_guide()
127
+ cmd = guide["commands"]["page.inspect"]
128
+ assert "--raw" in cmd["flags"]
129
+ assert "result_schema" in cmd
130
+ assert "examples" in cmd
131
+ assert "agent_hint" in cmd
132
+
133
+ def test_page_publish_has_title_inference_hint(self):
134
+ guide = build_guide()
135
+ cmd = guide["commands"]["page.publish"]
136
+ assert "agent_hint" in cmd
137
+ assert "title-cased" in cmd["agent_hint"]
138
+
139
+ def test_page_pull_has_progress_hint(self):
140
+ guide = build_guide()
141
+ cmd = guide["commands"]["page.pull"]
142
+ assert "agent_hint" in cmd
143
+ assert "progress" in cmd["agent_hint"].lower()
144
+
145
+ def test_page_delete_has_result_schema(self):
146
+ guide = build_guide()
147
+ cmd = guide["commands"]["page.delete"]
148
+ assert "result_schema" in cmd
149
+ assert "deleted_ids" in cmd["result_schema"]
150
+ assert "deleted_count" in cmd["result_schema"]
151
+
152
+ def test_plan_apply_has_result_schema(self):
153
+ guide = build_guide()
154
+ cmd = guide["commands"]["plan.apply"]
155
+ assert "result_schema" in cmd
156
+ assert "lockfile_path" in cmd["result_schema"]
157
+
158
+ def test_config_set_has_args_and_examples(self):
159
+ guide = build_guide()
160
+ cmd = guide["commands"]["config.set"]
161
+ assert "args" in cmd
162
+ assert "agent_hint" in cmd
163
+ assert "examples" in cmd
164
+ assert len(cmd["examples"]) > 0
165
+
166
+ def test_search_has_pagination_example(self):
167
+ guide = build_guide()
168
+ cmd = guide["commands"]["search"]
169
+ assert any("--start" in ex for ex in cmd["examples"])
170
+ assert "pagination" in cmd["agent_hint"].lower()
171
+
172
+ def test_lockfile_path_resolution_documented(self):
173
+ guide = build_guide()
174
+ behaviors = guide["lockfile"]["behavior"]
175
+ assert any("Path resolution" in b for b in behaviors)
@@ -110,7 +110,7 @@ class TestPersonalSpaceKeyCLI:
110
110
  """--space ~thro must arrive as literal '~thro' in the command handler."""
111
111
  captured = {}
112
112
 
113
- def fake_list_pages(self, space):
113
+ def fake_list_pages(self, space, **kwargs):
114
114
  captured["space"] = space
115
115
  return []
116
116
 
@@ -163,6 +163,51 @@ class TestSearchCommand:
163
163
  assert "search" in result.output
164
164
 
165
165
 
166
+ class TestQuietFlagPosition:
167
+ """Bug 1: --quiet and --verbose should work between group name and subcommand."""
168
+
169
+ def test_quiet_before_group(self):
170
+ result = runner.invoke(app, ["--quiet", "page", "--help"])
171
+ assert result.exit_code == 0
172
+
173
+ def test_quiet_between_group_and_command(self):
174
+ result = runner.invoke(app, ["page", "--quiet", "--help"])
175
+ assert result.exit_code == 0
176
+
177
+ def test_verbose_between_group_and_command(self):
178
+ result = runner.invoke(app, ["page", "--verbose", "--help"])
179
+ assert result.exit_code == 0
180
+
181
+ def test_quiet_on_plan_group(self):
182
+ result = runner.invoke(app, ["plan", "--quiet", "--help"])
183
+ assert result.exit_code == 0
184
+
185
+
186
+ class TestDeleteCascadeResult:
187
+ """Bug 3: Delete result should include deleted_ids and deleted_count."""
188
+
189
+ def test_delete_result_has_deleted_ids(self, monkeypatch):
190
+ from confpub.confluence import ConfluenceClient
191
+
192
+ mock_client = ConfluenceClient.__new__(ConfluenceClient)
193
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: mock_client)
194
+ monkeypatch.setattr(mock_client, "get_descendant_ids", lambda pid: ["child1", "child2"])
195
+ monkeypatch.setattr(mock_client, "_delete_descendants", lambda pid: None)
196
+ monkeypatch.setattr(mock_client, "delete_page", lambda pid: {"status": "deleted"})
197
+
198
+ # Prevent lockfile I/O
199
+ monkeypatch.setattr("confpub.lockfile.load_lockfile", lambda p: None)
200
+
201
+ result = runner.invoke(app, ["page", "delete", "--page-id", "123", "--cascade"])
202
+ assert result.exit_code == 0
203
+ data = json.loads(result.output)
204
+ assert data["ok"] is True
205
+ assert "deleted_ids" in data["result"]
206
+ assert "deleted_count" in data["result"]
207
+ assert data["result"]["deleted_count"] == 3 # page + 2 children
208
+ assert sorted(data["result"]["deleted_ids"]) == ["123", "child1", "child2"]
209
+
210
+
166
211
  class TestEnvelopeContract:
167
212
  def test_guide_returns_full_envelope(self):
168
213
  result = runner.invoke(app, ["guide"])
@@ -8,6 +8,7 @@ from confpub.lockfile import (
8
8
  LockPageEntry,
9
9
  Lockfile,
10
10
  load_lockfile,
11
+ remove_from_lockfile,
11
12
  save_lockfile,
12
13
  update_lockfile,
13
14
  )
@@ -110,3 +111,35 @@ class TestUpdateLockfile:
110
111
  update_lockfile(lf, "A", "1", 2)
111
112
  assert lf.pages["B"].page_id == "2"
112
113
  assert lf.pages["B"].version == 1
114
+
115
+
116
+ class TestRemoveFromLockfile:
117
+ def test_remove_existing_entry(self):
118
+ lf = Lockfile(pages={
119
+ "A": LockPageEntry(page_id="1", version=1),
120
+ "B": LockPageEntry(page_id="2", version=2),
121
+ })
122
+ result = remove_from_lockfile(lf, "A")
123
+ assert result is True
124
+ assert "A" not in lf.pages
125
+ assert "B" in lf.pages
126
+
127
+ def test_remove_nonexistent_entry(self):
128
+ lf = Lockfile(pages={
129
+ "A": LockPageEntry(page_id="1", version=1),
130
+ })
131
+ result = remove_from_lockfile(lf, "Missing")
132
+ assert result is False
133
+ assert "A" in lf.pages
134
+
135
+ def test_remove_from_empty_lockfile(self):
136
+ lf = Lockfile()
137
+ result = remove_from_lockfile(lf, "Anything")
138
+ assert result is False
139
+
140
+ def test_remove_all_entries(self):
141
+ lf = Lockfile(pages={
142
+ "A": LockPageEntry(page_id="1", version=1),
143
+ })
144
+ remove_from_lockfile(lf, "A")
145
+ assert len(lf.pages) == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes