Repository navigation
feat(cfl): add page export to PDF - #481
Conversation
Exporting a page to PDF was the one thing that sent a workflow back to the Confluence web UI, which matters when the page is a document that has to be sent to someone outside the site. Confluence Cloud publishes no REST endpoint for this. What it does have is the action its own UI navigates to, which both starts a server-side render and returns a page carrying the task identifier as metadata, plus a progress endpoint that reports the task and finally names the rendered document. `page export` drives those three legs: start, poll, download. Details worth knowing at the call site: - The XSRF header is what separates an accepted start request from a 403. - Confluence declares which generation of the progress endpoint served the request, and that also decides whether the task result is the document URL or a URL yielding one. Both are handled; neither is guessed. - The document is handed off to a media host with a signed URL that carries its own access, so the credential is sent only to the configured site and never to whatever host the task names. - A rejected credential is answered with a sign-in page under HTTP 200, so the download is checked for the PDF header rather than trusting the status. Progress goes to stderr and the success block to stdout, so redirecting stdout captures the result and nothing else. Without --output-file the page title names the file, reduced to a single path element.
The export drives an undocumented Confluence flow that Atlassian has moved before without notice, so the catalog is where a regression in it would be caught. Also records that bearer auth is unverified for this command rather than leaving the gap implied: the export goes through a web action rather than a REST endpoint, and whether the gateway proxies that action has not been established.
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: d53ff2b59a0e
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 1 |
| policies:conventions | 0 |
| security:code-auditor | 0 |
| architecture:solid-reviewer-agnostic | 2 |
go:implementation-tests (1 finding)
Minor - tools/cfl/internal/cmd/page/export.go:206
writeExport creates the output file and then streams the download into it with io.Copy, but if the reader errors partway through (a dropped connection while a large PDF renders and downloads), the partially written file is left on disk at outputPath with no cleanup. A retry without --force then fails with "file already exists" on a corrupt, truncated PDF rather than the partial file being removed so the user can simply rerun the command. None of the export_test.go cases exercise a reader that fails mid-copy, so this path is both unhandled and untested. Fix: on io.Copy error, remove the partially written file (os.Remove(outputPath)) before returning the error, and add a test using a reader that fails after writing some bytes to confirm the file does not survive a failed export.
architecture:solid-reviewer-agnostic (2 findings)
Minor - tools/cfl/api/export.go:138
U-L2: StartPDFExport turns HTTP status codes into untyped strings (
page %s not found, or not visible to this user,status %d), but the progress leg goes through the shared client'sGet, which returns typed errors fromsharederrors.ParseAPIError(ErrNotFound,ErrUnauthorized,ErrForbidden, ...). The result is that one command returns two different error kinds. A caller can matcherrors.Is(err, ErrForbidden)when polling is refused, but it cannot when the start request is refused with 403. That 403 is exactly the XSRF failure the PR description calls load-bearing. Fix: keep the user-facing text but wrap the sentinel, e.g.fmt.Errorf("starting export: page %s not found, or not visible to this user: %w", pageID, sharederrors.ErrNotFound). For other non-200 statuses, returnsharederrors.ParseAPIError(resp.StatusCode, body)wrapped with%w. Non-blocking becauseattachments.DownloadAttachmentalready uses untyped status strings, so this diff follows an existing precedent. It does add a second instance of that pattern.
Minor - tools/cfl/internal/cmd/page/export.go:24
U-D1: the poll cadence is a package const that awaitExport reads ambiently through
time.After(pollInterval). Tests cannot change it, so any test where the task does not finish on the first read waits a real 2 seconds per poll. TestRunExport_ReportsProgress already pays this cost: the page package tests take about 3.1s. It also makes multi-step progress cases, such as de-duplicating repeated percentages or running several steps before success, too slow to add. Fix: put the interval onexportOptions(defaultpollInterval, which tests set to milliseconds) or pass it toawaitExportas a parameter. This matches howtimeoutis already injected.
Reviewer Coverage
go:implementation-tests— complete (broad); inspected 7 assigned files (17 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/api/export_test.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/export_test.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go,tools/cfl/internal/present/mutation_test.go; skipped: none; constraints: nonepolicies:conventions— complete (broad); inspected 5 assigned files (17 inspected across reviewers):README.md,tools/cfl/README.md,tools/cfl/internal/cmd/OUTPUT_SPEC.md,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go; skipped: none; constraints: No sibling ../cli-common or ../.github checkouts were present, so family-wide Open CLI Collective standards (command-surface.md, output-and-rendering.md, repo-layout.md) could not be checked directly; review relied on repo-local docs and existing command precedent (attachment/download.go) instead.security:code-auditor— complete (broad); inspected 9 assigned files (17 inspected across reviewers):skills/Confluence/CliReference.md,skills/Confluence/Workflows/ManagePage.md,tools/cfl/CHANGELOG.md,tools/cfl/api/export.go,tools/cfl/api/testdata/pdf_export_progress_running.json,tools/cfl/api/testdata/pdf_export_progress_succeeded.json,tools/cfl/api/testdata/pdf_export_start.html,tools/cfl/integration-tests.md,tools/cfl/internal/cmd/page/export.go; skipped: none; constraints: nonearchitecture:solid-reviewer-agnostic— complete (broad); inspected 4 assigned files (17 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Ran go test for api, internal/cmd/page and internal/present in tools/cfl (all pass) and go vet ./... (clean); did not run full make check or golangci-lint.
Inspected files (17)
README.mdskills/Confluence/CliReference.mdskills/Confluence/Workflows/ManagePage.mdtools/cfl/CHANGELOG.mdtools/cfl/README.mdtools/cfl/api/export.gotools/cfl/api/export_test.gotools/cfl/api/testdata/pdf_export_progress_running.jsontools/cfl/api/testdata/pdf_export_progress_succeeded.jsontools/cfl/api/testdata/pdf_export_start.htmltools/cfl/integration-tests.mdtools/cfl/internal/cmd/OUTPUT_SPEC.mdtools/cfl/internal/cmd/page/export.gotools/cfl/internal/cmd/page/export_test.gotools/cfl/internal/cmd/page/page.gotools/cfl/internal/present/mutation.gotools/cfl/internal/present/mutation_test.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 4m 29s | ~$2.46 (est.) | claude-sonnet-5, claude-opus-5-5 | cr 0.10.318
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5-5 |
| Reviewers | go:implementation-tests, policies:conventions, security:code-auditor, architecture:solid-reviewer-agnostic |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 4m 29s wall · 7m 12s compute |
| Cost | ~$2.46 (est.) |
| Pricing basis | anthropic-public-2026-09-23 |
| Tokens | 94 in / 25.1k out |
Per-workstream usage
orchestrator-selection— claude-sonnet-5- In: 4
- Out: 4.9k
- Cache read: 34.5k
- Cache create: 41.9k
- Cost: ~$0.22 (est.)
- Duration: 46s
go:implementation-tests— claude-sonnet-5- In: 22
- Out: 5.7k
- Cache read: 668.2k
- Cache create: 68.4k
- Cost: ~$0.46 (est.)
- Duration: 1m 38s
policies:conventions— claude-sonnet-5- In: 26
- Out: 4.1k
- Cache read: 799.3k
- Cache create: 61.8k
- Cost: ~$0.45 (est.)
- Duration: 1m 34s
security:code-auditor— claude-sonnet-5- In: 16
- Out: 5.5k
- Cache read: 432.6k
- Cache create: 56.4k
- Cost: ~$0.37 (est.)
- Duration: 1m 34s
architecture:solid-reviewer-agnostic— claude-opus-5-5- In: 22
- Out: 4.3k
- Cache read: 560.7k
- Cache create: 72.4k
- Cost: ~$0.78 (est.)
- Duration: 1m 21s
orchestrator-rollup— claude-sonnet-5- In: 4
- Out: 557
- Cache read: 87.1k
- Cache create: 39.8k
- Cost: ~$0.18 (est.)
- Duration: 17s
The start leg of page export now maps error statuses through the shared API error parser, so a missing page reports ErrNotFound and a refused request ErrForbidden, the same as the progress leg and the rest of the client.
The document is streamed to a temporary file beside the destination and renamed into place only after the download completes, so a download cut off midway leaves no truncated file and an existing file replaced with --force survives a failed attempt. The progress poll interval is now a package variable that tests shorten, so polling tests no longer wait in real time. Faster polling exposed that a deadline lapsing during a progress read lost the --timeout hint; both paths now report it. Export fixtures for the command tests move to testdata, and a missing page no longer repeats the 'getting page' prefix.
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 4a09a391b5c0
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 0 |
| policies:conventions | 0 |
| security:code-auditor | 0 |
| architecture:solid-reviewer-agnostic | 0 |
Reviewer Coverage
go:implementation-tests— complete (constrained); inspected 7 assigned files (25 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/api/export_test.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/export_test.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go,tools/cfl/internal/present/mutation_test.go; skipped: none; constraints: nonepolicies:conventions— complete (constrained); inspected 5 assigned files (25 inspected across reviewers):README.md,tools/cfl/README.md,tools/cfl/internal/cmd/OUTPUT_SPEC.md,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go; skipped: none; constraints: No sibling ../cli-common or ../.github checkouts were present, so family-wide Open CLI Collective standards (command-surface.md, output-and-rendering.md, repo-layout.md) could not be checked directly; review relied on repo-local docs and existing command precedent (attachment/download.go) instead.security:code-auditor— complete (constrained); inspected 17 assigned files (25 inspected across reviewers):skills/Confluence/CliReference.md,skills/Confluence/Workflows/ManagePage.md,tools/cfl/CHANGELOG.md,tools/cfl/api/export.go,tools/cfl/api/testdata/pdf_export_progress_running.json,tools/cfl/api/testdata/pdf_export_progress_succeeded.json,tools/cfl/api/testdata/pdf_export_start.html,tools/cfl/api/testdata/pdf_export_start_forbidden.html,tools/cfl/api/testdata/pdf_export_start_not_found.html,tools/cfl/integration-tests.md,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/testdata/export_document.pdf,tools/cfl/internal/cmd/page/testdata/export_page.json,tools/cfl/internal/cmd/page/testdata/export_progress_failed.json,tools/cfl/internal/cmd/page/testdata/export_progress_running.json,tools/cfl/internal/cmd/page/testdata/export_progress_succeeded.json,tools/cfl/internal/cmd/page/testdata/export_start.html; skipped: none; constraints: nonearchitecture:solid-reviewer-agnostic— complete (constrained); inspected 4 assigned files (25 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Ran go test -count=1 for api, internal/cmd/page and internal/present with CGO_ENABLED=0 (all pass). -race could not run because the sandbox's clang module-cache path contains a space; make check and golangci-lint were not run. The earlier findings on typed start errors (U-L2) and the injectable poll interval (U-D1) are settled threads. I verified both fixes in 065286a and 4a09a39 and did not raise them again.
Inspected files (25)
README.mdskills/Confluence/CliReference.mdskills/Confluence/Workflows/ManagePage.mdtools/cfl/CHANGELOG.mdtools/cfl/README.mdtools/cfl/api/export.gotools/cfl/api/export_test.gotools/cfl/api/testdata/pdf_export_progress_running.jsontools/cfl/api/testdata/pdf_export_progress_succeeded.jsontools/cfl/api/testdata/pdf_export_start.htmltools/cfl/api/testdata/pdf_export_start_forbidden.htmltools/cfl/api/testdata/pdf_export_start_not_found.htmltools/cfl/integration-tests.mdtools/cfl/internal/cmd/OUTPUT_SPEC.mdtools/cfl/internal/cmd/page/export.gotools/cfl/internal/cmd/page/export_test.gotools/cfl/internal/cmd/page/page.gotools/cfl/internal/cmd/page/testdata/export_document.pdftools/cfl/internal/cmd/page/testdata/export_page.jsontools/cfl/internal/cmd/page/testdata/export_progress_failed.jsontools/cfl/internal/cmd/page/testdata/export_progress_running.jsontools/cfl/internal/cmd/page/testdata/export_progress_succeeded.jsontools/cfl/internal/cmd/page/testdata/export_start.htmltools/cfl/internal/present/mutation.gotools/cfl/internal/present/mutation_test.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 1m 56s | ~$1.56 (est.) | claude-sonnet-5, claude-opus-5-5 | cr 0.10.318
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5-5 |
| Reviewers | go:implementation-tests, policies:conventions, security:code-auditor, architecture:solid-reviewer-agnostic |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 1m 56s wall · 3m 37s compute |
| Cost | ~$1.56 (est.) |
| Pricing basis | anthropic-public-2026-09-23 |
| Tokens | 54 in / 8.9k out |
Per-workstream usage
go:implementation-tests— claude-sonnet-5- In: 10
- Out: 1.5k
- Cache read: 429.0k
- Cache create: 79.0k
- Cost: ~$0.42 (est.)
- Duration: 39s
policies:conventions— claude-sonnet-5- In: 8
- Out: 1.8k
- Cache read: 296.0k
- Cache create: 66.9k
- Cost: ~$0.35 (est.)
- Duration: 32s
security:code-auditor— claude-sonnet-5- In: 10
- Out: 2.2k
- Cache read: 388.5k
- Cache create: 68.6k
- Cost: ~$0.37 (est.)
- Duration: 42s
architecture:solid-reviewer-agnostic— claude-opus-5-5- In: 20
- Out: 3.0k
- Cache read: 784.9k
- Cache create: 13.7k
- Cost: ~$0.33 (est.)
- Duration: 1m 22s
orchestrator-rollup— claude-sonnet-5- In: 6
- Out: 431
- Cache read: 200.7k
- Cache create: 12.3k
- Cost: ~$0.09 (est.)
- Duration: 20s
The temporary file behind the atomic write was created with os.CreateTemp, which fixes its mode at 0600. It is now opened with O_EXCL and mode 0666 under a random name beside the destination, retried a bounded number of times on a taken name, so the finished document gets the same permissions os.Create gives an attachment download.
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 58f3d1209710
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 0 |
| policies:conventions | 0 |
| security:code-auditor | 0 |
| architecture:solid-reviewer-agnostic | 0 |
Reviewer Coverage
go:implementation-tests— complete (constrained); inspected 7 assigned files (25 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/api/export_test.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/export_test.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go,tools/cfl/internal/present/mutation_test.go; skipped: none; constraints: nonepolicies:conventions— complete (constrained); inspected 5 assigned files (25 inspected across reviewers):README.md,tools/cfl/README.md,tools/cfl/internal/cmd/OUTPUT_SPEC.md,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go; skipped: none; constraints: No sibling ../cli-common or ../.github checkouts were present, so family-wide Open CLI Collective standards (command-surface.md, output-and-rendering.md, repo-layout.md) could not be checked directly; review relied on repo-local docs and existing command precedent (attachment/download.go) instead.security:code-auditor— complete (constrained); inspected 17 assigned files (25 inspected across reviewers):skills/Confluence/CliReference.md,skills/Confluence/Workflows/ManagePage.md,tools/cfl/CHANGELOG.md,tools/cfl/api/export.go,tools/cfl/api/testdata/pdf_export_progress_running.json,tools/cfl/api/testdata/pdf_export_progress_succeeded.json,tools/cfl/api/testdata/pdf_export_start.html,tools/cfl/api/testdata/pdf_export_start_forbidden.html,tools/cfl/api/testdata/pdf_export_start_not_found.html,tools/cfl/integration-tests.md,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/testdata/export_document.pdf,tools/cfl/internal/cmd/page/testdata/export_page.json,tools/cfl/internal/cmd/page/testdata/export_progress_failed.json,tools/cfl/internal/cmd/page/testdata/export_progress_running.json,tools/cfl/internal/cmd/page/testdata/export_progress_succeeded.json,tools/cfl/internal/cmd/page/testdata/export_start.html; skipped: none; constraints: nonearchitecture:solid-reviewer-agnostic— complete (constrained); inspected 4 assigned files (25 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Ran go test -count=1 for api, internal/cmd/page and internal/present with CGO_ENABLED=0 (all pass). -race could not run because the sandbox's clang module-cache path contains a space; make check and golangci-lint were not run. The earlier findings (typed start errors and the injectable poll interval) are settled threads and were not raised again. Only 58f3d12 (createExportTemp, umask-derived mode) is new since the last pass; it changes only export.go and its test.
Inspected files (25)
README.mdskills/Confluence/CliReference.mdskills/Confluence/Workflows/ManagePage.mdtools/cfl/CHANGELOG.mdtools/cfl/README.mdtools/cfl/api/export.gotools/cfl/api/export_test.gotools/cfl/api/testdata/pdf_export_progress_running.jsontools/cfl/api/testdata/pdf_export_progress_succeeded.jsontools/cfl/api/testdata/pdf_export_start.htmltools/cfl/api/testdata/pdf_export_start_forbidden.htmltools/cfl/api/testdata/pdf_export_start_not_found.htmltools/cfl/integration-tests.mdtools/cfl/internal/cmd/OUTPUT_SPEC.mdtools/cfl/internal/cmd/page/export.gotools/cfl/internal/cmd/page/export_test.gotools/cfl/internal/cmd/page/page.gotools/cfl/internal/cmd/page/testdata/export_document.pdftools/cfl/internal/cmd/page/testdata/export_page.jsontools/cfl/internal/cmd/page/testdata/export_progress_failed.jsontools/cfl/internal/cmd/page/testdata/export_progress_running.jsontools/cfl/internal/cmd/page/testdata/export_progress_succeeded.jsontools/cfl/internal/cmd/page/testdata/export_start.htmltools/cfl/internal/present/mutation.gotools/cfl/internal/present/mutation_test.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 1m 08s | ~$1.13 (est.) | claude-sonnet-5, claude-opus-5-5 | cr 0.10.318
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5-5 |
| Reviewers | go:implementation-tests, policies:conventions, security:code-auditor, architecture:solid-reviewer-agnostic |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 1m 08s wall · 2m 27s compute |
| Cost | ~$1.13 (est.) |
| Pricing basis | anthropic-public-2026-09-23 |
| Tokens | 44 in / 6.5k out |
Per-workstream usage
go:implementation-tests— claude-sonnet-5- In: 12
- Out: 1.5k
- Cache read: 705.1k
- Cache create: 44.2k
- Cost: ~$0.33 (est.)
- Duration: 40s
policies:conventions— claude-sonnet-5- In: 10
- Out: 1.4k
- Cache read: 520.4k
- Cache create: 37.8k
- Cost: ~$0.27 (est.)
- Duration: 24s
security:code-auditor— claude-sonnet-5- In: 8
- Out: 1.7k
- Cache read: 408.4k
- Cache create: 44.8k
- Cost: ~$0.28 (est.)
- Duration: 36s
architecture:solid-reviewer-agnostic— claude-opus-5-5- In: 8
- Out: 1.5k
- Cache read: 347.7k
- Cache create: 7.4k
- Cost: ~$0.16 (est.)
- Duration: 31s
orchestrator-rollup— claude-sonnet-5- In: 6
- Out: 373
- Cache read: 218.6k
- Cache create: 11.2k
- Cost: ~$0.09 (est.)
- Duration: 14s
…ort-pdf # Conflicts: # tools/cfl/CHANGELOG.md
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 50428da16bc6
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 0 |
| policies:conventions | 0 |
| security:code-auditor | 0 |
| architecture:solid-reviewer-agnostic | 0 |
Reviewer Coverage
go:implementation-tests— complete (constrained); inspected 7 assigned files (25 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/api/export_test.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/export_test.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go,tools/cfl/internal/present/mutation_test.go; skipped: none; constraints: This revision is a merge of main into the PR branch; the assigned files are unchanged from the prior reviewed revision, which already had all findings resolved.policies:conventions— complete (constrained); inspected 5 assigned files (25 inspected across reviewers):README.md,tools/cfl/README.md,tools/cfl/internal/cmd/OUTPUT_SPEC.md,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go; skipped: none; constraints: No sibling ../cli-common or ../.github checkouts were present, so family-wide Open CLI Collective standards (command-surface.md, output-and-rendering.md, repo-layout.md) could not be checked directly; review relied on repo-local docs and existing command precedent (attachment/download.go) instead.security:code-auditor— complete (constrained); inspected 17 assigned files (25 inspected across reviewers):skills/Confluence/CliReference.md,skills/Confluence/Workflows/ManagePage.md,tools/cfl/CHANGELOG.md,tools/cfl/api/export.go,tools/cfl/api/testdata/pdf_export_progress_running.json,tools/cfl/api/testdata/pdf_export_progress_succeeded.json,tools/cfl/api/testdata/pdf_export_start.html,tools/cfl/api/testdata/pdf_export_start_forbidden.html,tools/cfl/api/testdata/pdf_export_start_not_found.html,tools/cfl/integration-tests.md,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/testdata/export_document.pdf,tools/cfl/internal/cmd/page/testdata/export_page.json,tools/cfl/internal/cmd/page/testdata/export_progress_failed.json,tools/cfl/internal/cmd/page/testdata/export_progress_running.json,tools/cfl/internal/cmd/page/testdata/export_progress_succeeded.json,tools/cfl/internal/cmd/page/testdata/export_start.html; skipped: none; constraints: nonearchitecture:solid-reviewer-agnostic— complete (constrained); inspected 4 assigned files (25 inspected across reviewers):tools/cfl/api/export.go,tools/cfl/internal/cmd/page/export.go,tools/cfl/internal/cmd/page/page.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Ran go test -count=1 for api, internal/cmd/page and internal/present with CGO_ENABLED=0 on the merged head (all pass). -race could not run because the sandbox's clang module-cache path contains a space; make check and golangci-lint were not run. Since the last pass (58f3d12), the only commits are merges from main; none of the assigned files changed. The earlier findings are settled threads and were not raised again.
Inspected files (25)
README.mdskills/Confluence/CliReference.mdskills/Confluence/Workflows/ManagePage.mdtools/cfl/CHANGELOG.mdtools/cfl/README.mdtools/cfl/api/export.gotools/cfl/api/export_test.gotools/cfl/api/testdata/pdf_export_progress_running.jsontools/cfl/api/testdata/pdf_export_progress_succeeded.jsontools/cfl/api/testdata/pdf_export_start.htmltools/cfl/api/testdata/pdf_export_start_forbidden.htmltools/cfl/api/testdata/pdf_export_start_not_found.htmltools/cfl/integration-tests.mdtools/cfl/internal/cmd/OUTPUT_SPEC.mdtools/cfl/internal/cmd/page/export.gotools/cfl/internal/cmd/page/export_test.gotools/cfl/internal/cmd/page/page.gotools/cfl/internal/cmd/page/testdata/export_document.pdftools/cfl/internal/cmd/page/testdata/export_page.jsontools/cfl/internal/cmd/page/testdata/export_progress_failed.jsontools/cfl/internal/cmd/page/testdata/export_progress_running.jsontools/cfl/internal/cmd/page/testdata/export_progress_succeeded.jsontools/cfl/internal/cmd/page/testdata/export_start.htmltools/cfl/internal/present/mutation.gotools/cfl/internal/present/mutation_test.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 2m 49s | ~$3.96 (est.) | claude-sonnet-5, claude-opus-5-5 | cr 0.10.318
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5-5 |
| Reviewers | go:implementation-tests, policies:conventions, security:code-auditor, architecture:solid-reviewer-agnostic |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 2m 49s wall · 6m 58s compute |
| Cost | ~$3.96 (est.) |
| Pricing basis | anthropic-public-2026-09-23 |
| Tokens | 40 in / 6.6k out |
Per-workstream usage
go:implementation-tests— claude-sonnet-5- In: 10
- Out: 1.6k
- Cache read: 495.8k
- Cache create: 213.6k
- Cost: ~$0.97 (est.)
- Duration: 1m 39s
policies:conventions— claude-sonnet-5- In: 10
- Out: 1.8k
- Cache read: 457.2k
- Cache create: 188.3k
- Cost: ~$0.86 (est.)
- Duration: 1m 40s
security:code-auditor— claude-sonnet-5- In: 8
- Out: 1.4k
- Cache read: 331.1k
- Cache create: 192.5k
- Cost: ~$0.85 (est.)
- Duration: 1m 30s
architecture:solid-reviewer-agnostic— claude-opus-5-5- In: 8
- Out: 1.6k
- Cache read: 289.3k
- Cache create: 93.2k
- Cost: ~$0.83 (est.)
- Duration: 1m 40s
orchestrator-rollup— claude-sonnet-5- In: 4
- Out: 343
- Cache read: 53.9k
- Cache create: 108.0k
- Cost: ~$0.45 (est.)
- Duration: 26s
Summary
Adds
cfl page export <page-id>, which writes a Confluence page out as a PDF.Everything else about a page is reachable from the CLI, but exporting one was
not, so any workflow that ends in "send this page to someone as a document" had
to stop and go click Export to PDF in the web UI. This closes that gap.
Why it works the way it does
Confluence Cloud publishes no REST endpoint for PDF export. The request has
been open since 2018 (CONFCLOUD-61557,
still Gathering Interest), and the Atlassian KB article that describes a
302straight to a PDF is scoped to Data Center and does not apply here. Thev1andv2API groups have no export surface at all. I confirmed both againsta live Cloud site rather than taking the docs' word for it:
GET /wiki/api/v2/pages/{id}/export404GET /wiki/rest/api/content/{id}/export404GET /wiki/rest/api/content/{id}/exportpdf404What does exist is the flow the Confluence UI itself uses, and it works with the
API-token auth
cflalready has. It has three legs:GET /wiki/spaces/flyingpdf/pdfpageexport.action?pageId=<id>starts aserver-side render and returns an HTML progress page. The task identifier is
published only as
<meta name="ajs-taskId">inside that page, which is whythis leg parses HTML. A sibling
ajs-isV3declares which progress endpointserved the request.
GET /wiki/api/v2/pdfexporttask/progress/<taskId>reports{progress, state, result}untilstatereachesSUCCEEDED.resultnames the rendered document, which is then downloaded.Four behaviors here are not obvious and each one is load-bearing:
X-Atlassian-Token: no-checkthe start request is refused with
403; with it,200. I hit exactly thiswhile building it.
is the only thing that separates them. A bogus action path returns
404whilepdfpageexport.actionreturns200, which is the control that establishes theroute is real rather than being swallowed by the SPA router.
inverts between them. The document is normally handed to a media host with a
signed URL carrying its own access, where sending the Atlassian credential is
both wrong and a credential leak to another host. A site-relative result needs
the credential. Sending it is therefore decided by where the URL points.
status code cannot catch that, so the download is verified to start with
%PDF-and fails withErrExportNotPDFotherwise, rather than writing an HTMLpage to disk under a
.pdfname.Both progress-endpoint generations are supported because the page declares which
one applies; the pre-v3 shape adds one indirection (the result addresses a
resource whose body is the document URL). Neither shape is guessed: both come
from the export script Confluence itself ships.
Verification
Run against a live Confluence Cloud site with the ordinary API-token config:
Guard paths, also run live:
Re-run on 2026-09-30 after merging current
main, built from the branch head, against a live Cloud site with API-token auth: a small page exported in about 11 seconds to a 173.3 KB file that starts with%PDF-1.4and thatpdfinforeads as 3 pages. The existing-file guard refused a second run and--forceoverwrote it.make checkis green (tidy, lint, test, build).go test -race ./...andgolangci-lint run ./...intools/cflalso pass on the merged head (0 lint issues).Notes for review
progress endpoint changed in April 2026, breaking the recipes in the wild).
That is an argument for the failure modes above being explicit rather than for
avoiding the path, since it is the only one that produces a PDF at all. The
alternative considered was rendering the page body locally, which would produce
a document that does not match what Confluence's own export produces.
attachment downloadprecedent (-O/--output-file,-f/--force) and the mutation contract inOUTPUT_SPEC.md: a success summaryplus follow-up fields, progress on stderr so stdout stays clean for scripting.
--formatcurrently accepts onlypdf. It is there so the closed set isstated and rejected loudly rather than a second format being bolted on later
by positional convention.
api/testdata/.Docs
tools/cfl/README.md, rootREADME.md,internal/cmd/OUTPUT_SPEC.md,skills/Confluence/CliReference.md,skills/Confluence/Workflows/ManagePage.md,and the cfl changelog.
tools/cfl/integration-tests.mdgains apage exportsection. That catalog iswhere a break in the undocumented flow would actually be caught, and it also
records the one thing I could not establish: bearer auth is unverified for
this command. The export goes through a web action rather than a REST
endpoint, and whether the
api.atlassian.comgateway proxies that action hasnot been tested. I would rather state that than let it be assumed.