Markdown-centric Confluence cli tool. Works with Claude, works with GitHub actions, works with you.
Requires Go 1.25+.
git clone https://github.com/mozilla/markfluence
cd markfluence
make install # installs `markfluence` into your Go bin
# ...or, to build into ./bin without installing:
make build # produces ./bin/markfluenceTBD — published to a tap on the first release.
markfluence generates its own completion scripts for bash, zsh, fish, and
PowerShell. The release archives ship them under completions/, and a Homebrew
install puts them where each shell looks, so a brew install needs nothing
further.
Otherwise, to load them into the current shell:
source <(markfluence completion bash) # bash
source <(markfluence completion zsh) # zsh
markfluence completion fish | source # fishTo install them permanently, markfluence completion <shell> --help prints the
path your shell reads on your platform. For example, on Linux with bash:
markfluence completion bash > /etc/bash_completion.d/markfluenceCompletion offers Markdown files wherever a FILE or PAGE argument goes (the
page-id and URL forms of PAGE you type out), the values of flags like
--page-width and --format, and directories for --dest. Attachment names
aren't completed: they live on the server, and completion never makes a network
call.
markfluence needs a Confluence site URL, a username, and an API token. Each is
resolved with the precedence flag > environment variable > .env file:
| Setting | Flag | Environment / .env |
|---|---|---|
| Site URL | --url |
CONFLUENCE_URL |
| Username | --username |
CONFLUENCE_USERNAME |
| API token | (none — never a flag) | CONFLUENCE_TOKEN |
| Cloud ID (optional) | --cloud-id |
CONFLUENCE_CLOUD_ID |
markfluence reads a .env file from the current directory automatically (no need
to source it), or from an explicit path via --env-file PATH.
Copy .env.example to .env and fill in:
CONFLUENCE_URL=https://your-org.atlassian.net
CONFLUENCE_USERNAME=you@example.com
CONFLUENCE_TOKEN=your-api-token
# Optional. Set this only when using *scoped* API tokens.
# CONFLUENCE_CLOUD_ID=
Note
The API token is deliberately not accepted as a command-line flag; it comes
only from the environment or .env.
(Optional): alias mf=markfluence
For a normal personal API token, you can leave CONFLUENCE_CLOUD_ID unset.
For a scoped API token for an Atlassian service account, you
need to set CONFLUENCE_CLOUD_ID. You would use a scoped API token for a
service account to publish from CI or other automated system. Scoped tokens are
rejected with a 401 against your site domain--markfluence must use
Atlassian's api.atlassian.com gateway, and the cloud ID is required there.
CONFLUENCE_URL still holds the site URL: markfluence uses it to write correct
links into the pages it publishes.
To find your cloud ID, you can do this:
$ curl -s https://your-org.atlassian.net/_edge/tenant_info
{"cloudId":"d8febd08-5555-5555-5555-db37c2369ce5"}The cloud ID is not a secret.
The scopes markfluence needs:
| Used for | Commands | Scope |
|---|---|---|
| Reading pages, and reading/writing page width | all but schema |
read:page:confluence |
| Creating and updating pages, and setting page width | create, update, fix |
write:page:confluence |
| Resolving a space key to an id | create, find, search |
read:space:confluence |
| Looking up a folder (a folder can be a page's parent) | create, children, find |
read:folder:confluence |
| CQL queries | find, search |
search:confluence |
| Author names | info |
read:confluence-user |
| Uploading image attachments | create, update, attachment-upload |
write:confluence-file |
| Downloading attachments | export, attachment-download |
readonly:content.attachment:confluence |
| Listing attachments and child pages/folders | children, export, read, attachment-list |
read:confluence-content.summary |
Copy-pasteable:
read:page:confluence
write:page:confluence
read:space:confluence
read:folder:confluence
search:confluence
read:confluence-user
write:confluence-file
readonly:content.attachment:confluence
read:confluence-content.summary
Important
The mixture of naming styles is correct, not a copy-paste error. Atlassian
has two scope vocabularies — classic (read:confluence-user) and granular
(read:page:confluence) — and they are granted independently: holding one does
not imply the other. markfluence talks to both API versions, and each
version accepts only one vocabulary, so the list above is genuinely mixed. A
token granted the classic names alone fails with
401 Unauthorized; scope does not match on almost every command, which is what
makes this an easy list to get wrong. The measurements behind that are in
docs/confluence/api.md.
Note
Scopes are fixed when a token is issued. A missing one needs a new token, not an edit to the existing one.
Note
Currently, markfluence doesn't support deleting anything, so it doesn't need delete scopes. This might change in the future.
A 403 (rather than the 401 above) means the opposite problem: the token is scoped for the call, but the service account lacks Confluence permission on that space or page. Grant the account access; a new token will not help.
Atlassian offers no way to introspect a token, but the scope gate runs before routing and validation, so one request per scope tells you. Aim it at an id that does not exist — it reads nothing and creates nothing:
$ CID=your-cloud-id
$ curl -s -o /dev/null -w '%{http_code}\n' -u "$CONFLUENCE_USERNAME:$CONFLUENCE_TOKEN" \
"https://api.atlassian.com/ex/confluence/$CID/wiki/api/v2/pages/999999999999"
401- 401 — the scope is missing.
- any other 4xx (400/403/404/415) — the request got past the scope gate, so the scope is present; it failed later for an unrelated reason.
Swap the path for the one whose scope you want to test, using the table above to map scope to route.
General:
markfluence --help
markfluence schema --helpManipulating Confluence pages:
markfluence create --help
markfluence update --help
markfluence fix --help
markfluence info --help
markfluence read --help
markfluence find --help
markfluence search --help
markfluence children --help
markfluence export --helpManipulating Confluence page attachments:
markfluence attachment-list --help
markfluence attachment-upload --help
markfluence attachment-download --helpEvery command that takes a page accepts three forms: a numeric page id, a
Confluence page URL, or a Markdown file whose frontmatter has a page_id.
Usage: markfluence create FILE... [flags]
Create new Confluence pages from Markdown files.
The page title comes from frontmatter, or from --title (which overrides the
frontmatter and requires a single FILE).
Confluence space can be specified on the command line (--space SPACE) or
in the frontmatter.
Optional parent can be specified on the command line (--parent PAGE_ID) or
in the frontmatter. In the frontmatter, you can specify the page id or
the Markdown file. The parent may also be a folder — the Confluence Cloud
content type — in which case give its id the same way you would a page's.
Page width defaults to max; set it with --page-width narrow|wide|max (which
overrides the frontmatter page_width and may apply across a batch).
All files are validated first — if any would fail (a problem with its page_id, a
title clash in the space, an unresolvable parent), nothing is created. Both kinds
of clash name the page in the way, so you can go look at it:
$ markfluence create docs/runbook.md
[docs/runbook.md] a page already exists at page_id 123 ("Deploy Runbook"): https://wiki.example.net/wiki/spaces/ENG/pages/123/Deploy+Runbook
Aborting: 1 file(s) failed validation; nothing was created.A file whose page_id doesn't resolve is also a failure, not a fresh page:
create will not publish a second copy and overwrite the id it can't explain.
Remove the page_id to create a new page, or correct it. A page_id that isn't a
numeric id at all (a pasted URL, a leftover placeholder) is reported as such
without asking Confluence about it.
On success title, space, parent, page_id, and page_width are written
back into each file — unless --no-persist is given, in which case nothing is
written back (and the file won't record its new page_id).
A whole tree can be created in one pass: give each child a parent: that points at
its parent's .md file, and create orders creation parents-first and fills in the
real ids (see the parent field below).
--dry-run validates every file (the same checks a real run makes, so it exits
non-zero on the same failures) and previews what would be created — pages,
attachment uploads, page widths, and frontmatter write-backs — without writing to
Confluence or to any file. Because nothing is created, a previewed page has no id
or URL yet; an in-set child's parent is unresolved, but its source file is
reported in the parent_file output field (present in every run, in --json).
markfluence create docs/new_page.md --space ENG
markfluence create docs/child.md --space ENG --parent 123456
markfluence create docs/*.md --space ENG # hierarchy via parent: paths
markfluence create note.md --space ENG --title "Ad-hoc note" --page-width wide
markfluence create note.md --space ENG --no-persist # create without touching the file
markfluence create docs/*.md --space ENG --dry-run # preview; write nothingUsage: markfluence update FILE... [flags]
Update one or more Markdown files in Confluence.
Page id and title are read from frontmatter. A page_id is required (from
frontmatter or --page-id); update errors if none is set. --title and
--page-id override the frontmatter and require a single FILE; --title
renames the page, and a title otherwise falls back to the live page's title.
Page width is asserted only when --page-width is passed or a page_width
frontmatter line is present — otherwise the live page's width is left untouched.
update never writes back to the file.
A page_id that no longer resolves fails that file with what to do about it
(page_id 999 not found (deleted, trashed, or wrong); correct it, or remove it and use create instead), and one that isn't a numeric id at all is reported without
asking Confluence. Since update writes nothing back, fixing the id is always
safe: the file is exactly as you left it.
Updates are skipped when a file hasn't changed since the page's last version
(compared by mtime) unless --force is given. Each file is processed
independently; the command exits non-zero if any file fails.
--dry-run previews what would be published — the version bump, attachment
uploads, and any page-width change — without writing to Confluence. It honors the
mtime skip and --force just like a real run, so its forecast matches what a real
run would do.
markfluence update docs/managing_an_incident.md
markfluence update docs/*.md --message "Bulk update"
markfluence update docs/foo.md --force # ignore the mtime check
markfluence update page.md --page-id 123456 # override the target page
markfluence update page.md --title "New Title" # override / rename
markfluence update docs/*.md --page-width wide # set width across a batch
markfluence update docs/*.md --dry-run # preview; write nothingUsage: markfluence fix FILE... [flags]
Reconcile each file's frontmatter (page_id, space, parent, page_width, and
a missing title) to match its live Confluence page. The page is located by
page_id, or by searching for the title when page_id is absent. fix never
creates, updates, or moves pages — it's read-only on the server and writes a file
only when a field actually changed. --dry-run reports the changes without writing.
markfluence fix docs/*.md
markfluence fix docs/foo.md --dry-runUsage: markfluence info PAGE [flags]
Print a page's metadata (id, title, status, space, parent, version, page width,
authors, dates, url). PAGE is a numeric page id, a Confluence page URL, or a
Markdown file whose frontmatter has a page_id. --properties also lists all of
the page's content properties.
markfluence info 1234567890
markfluence info docs/foo.md --propertiesUsage: markfluence read PAGE [flags]
Fetch a Confluence page and print its body to stdout. PAGE is a numeric page id,
a Confluence page URL (the modern /wiki/.../pages/<id>/... form or a legacy
?pageId=<id> URL), or a Markdown file whose frontmatter has a page_id. It
composes with shell redirection.
--format selects the output:
markdown(default) — the page converted to GitHub-Flavored Markdown, withtitle/page_id/space/page_widthfrontmatter, i.e. a best-effort inverse of whatcreate/updatepublish. The Confluence API has no markdown representation, so markfluence converts the storage body itself: constructs markfluence emits round-trip faithfully, while editor-authored content degrades gracefully — any macro markfluence doesn't map (panels, expand, status, …) and column layouts pass through as raw storage tags, with a macro/cell body kept as readable markdown, so they round-trip back throughcreate/update. Some transforms are lossy (e.g.CAUTIONalerts, internal links, original image paths, and table cell background colors cannot be recovered), so this is a reading aid, not a guaranteed source round-trip.storage— the page's raw storage-format XHTML, exactly as stored.
markfluence read 1234567890 # markdown, with frontmatter
markfluence read 1234567890 > page.md
markfluence read 1234567890 --format storage > page.storage.xml
markfluence read "https://org.atlassian.net/wiki/spaces/ENG/pages/1234567890/Title"Usage: markfluence children PAGE [flags]
List the pages and folders under a page or folder. PAGE is a numeric id, a
Confluence page or folder URL, or a markdown file whose frontmatter has a
page_id.
markfluence children 1234567890 # direct children
markfluence children 1234567890 --depth 3
markfluence children 1234567890 --depth all
markfluence children "https://org.atlassian.net/wiki/spaces/ENG/folder/1234567890"
markfluence children docs/index.md # children of the page index.md publishes to
markfluence children 1234567890 --json | jq -r '.results[] | select(.type=="page") | .id'TYPE ID TITLE
folder 2876047392 Articles
page 1675427879 MozCloud planning
page 1671692338 MozCloud observability focus and issues
Titles indent by depth; TYPE and ID stay aligned so the output is still
greppable. Siblings appear in the order Confluence displays them, which takes a
merge — pages and folders come from separate requests.
Folders are listed, not just traversed. A folder can hold the only pages in a
subtree, so listing pages alone would show nothing for a folder that contains
folders. A folder also counts as a level: at the default --depth 1 a child
folder appears as a row, and --depth 2 shows what is inside it.
--depth takes a positive number or all, defaulting to 1. 0 is rejected
rather than treated as "unlimited" — a common convention elsewhere, and silently
walking an entire space for someone who meant "none" is worse than an error.
--depth all is genuinely unbounded: it costs two requests per node, and the
default of 1 is what keeps the casual case cheap.
Trashed pages and folders are not listed. Finding nothing is a success, not a
failure: the command prints No children. and exits 0, so --json reporting an
empty results array is how a script tests for an empty subtree.
Usage: markfluence find TITLE [flags]
Find the pages and folders whose title is TITLE. This is the one handle the
other commands cannot resolve: they take a page id, a page URL, or a Markdown
file with a page_id, and find is how you get from a title to one of those.
If you do not know the title, use search instead.
markfluence find "Deploy runbook"
markfluence find "Deploy runbook" --space ENG
markfluence find "Deploy runbook" --json | jq -r '.results[] | select(.type=="page") | .id'TYPE ID SPACE STATUS TITLE URL
folder 2950660103 CLOUDSERVICES current Deploy runbook https://org.atlassian.net/wiki/spaces/CLOUDSERVICES/folder/2950660103
page 5144768 CEX current Deploy runbook https://org.atlassian.net/wiki/spaces/CEX/pages/5144768/Deploy+runbook
page 3277005 AVSE archived Deploy runbook https://org.atlassian.net/wiki/spaces/AVSE/pages/3277005/Deploy+runbook
The match is exact and case-insensitive — not a substring search. Results are
ordered by space, then type, then id. --space takes a space key, never a
numeric space id, the same rule frontmatter follows; an unknown key is an error
rather than an empty result, since a typo would otherwise be indistinguishable
from "no such page".
Archived pages are included, and marked. An archived page does not appear in
the page tree, but it still holds its title — create in that space will be
refused until it is restored or renamed. That is the main reason to run find
before publishing, and why the STATUS column is not decoration.
Folders are included too, but they never explain a conflict. A folder id is
a legitimate parent, so being able to look one up by name is useful. A folder
does not reserve a title: a page can be created with a folder's exact name in
the same space. So a folder row is somewhere to publish, never a reason you
cannot.
Trashed pages are not listed. Finding nothing is a success: the command prints
No matches found. and exits 0, so --json reporting an empty results array
is how a script tests "does this title exist yet?" before deciding to create or
update.
Answering takes two requests, because no single Confluence API can see all three: the v2 pages route covers current and archived pages but cannot see a folder, while CQL covers folders but cannot see an archived page. If either request fails the whole command fails — half an answer here reads as "nothing found", which is the one wrong answer that causes a duplicate. The details are in docs/confluence/search.md.
Usage: markfluence search QUERY [flags]
Find pages by full text, for when you do not know the title. find answers
"does a page called this exist?"; search answers "where is the page about
deploys?".
markfluence search "deploy runbook"
markfluence search "deploy runbook" --space ENG --limit 25
markfluence search deploy --limit all --json | jq -r '.results[].id'
markfluence search 'type = page and label = "runbook"' --cqlDeployment runbook
page 2064154670 PXI
https://org.atlassian.net/wiki/spaces/PXI/pages/2064154670/Deployment+runbook
Keep an eye on the deploy-packages job...to be deployed)
Runbook: Prod deployment
page 1293156436 CLOUDSERVICES
https://org.atlassian.net/wiki/spaces/CLOUDSERVICES/pages/1293156436/Runbook+Prod+deployment
"Deploy failed" message in #crash-ingestion-bots...look at logs in Github Actions
Showing 2 matches; more exist (use --limit all).
Each hit is a block rather than a table row, because the excerpt is what tells you why it matched, and an excerpt is too long for a column.
Matched terms in the excerpt are shown in reverse video, using the positions
Confluence reports rather than by matching your query text — so the highlight
follows the server's own stemming (searching deploy marks deploys) and works
under --cql, where there are no query words to match against. Some hits come
back without them; Confluence marked 40 of 50 sampled rows. The highlight disappears
under --no-color, under NO_COLOR, and whenever output is piped or
redirected, so a captured excerpt is plain text. --json is unaffected: its
excerpt is the same plain string it has always been.
Multiple words are ANDed, and it is not a phrase search. Every word must
appear somewhere in the page, in any order — "deploy runbook" and
"runbook deploy" return the same set. Adding a word narrows the search;
quoting does not require the words to be adjacent.
Results are in Confluence's relevance order, best first, and markfluence never
re-sorts them. The API reports a relevance score of 0.0 on every row, so the
order it returns is the only ranking that exists — which is also why a --json
consumer should not sort results.
--limit defaults to 10, and never truncates silently. A hit is a block of
5–6 lines rather than a row, so ten is about a screen. It takes a positive
number or all; 0 is refused rather than read as "unlimited", the same rule
--depth follows. When there are more matches than were shown, the command says
so. It reports that more exist rather than how many, because the API's own
total is an estimate that disagrees with what it returns.
--type defaults to page, and also accepts blogpost or all. The search
index holds attachments, comments, databases and whiteboards too, and all of them
match text — but their ids are not something any other markfluence command
accepts, so they are behind all. --type folder is refused with a pointer to
find: a folder has no text, so it can never match a full-text query.
--cql passes QUERY straight through as CQL,
with no escaping and no clauses added. It cannot be combined with --space or
--type: those would have to be ANDed onto your query, which would regroup a
query containing or and silently answer something else. Put the clauses in the
query yourself. --limit still applies, since it bounds paging rather than the
query.
Two things search cannot find. Archived pages are invisible to the search
index entirely, and so are folders. Both are what find is for. The index also
lags by up to about a minute, so a page created moments ago may not be there yet
— anything that has to be correct now should use find.
Finding nothing is a success: the command prints No matches found. and exits 0.
The evidence behind the query it builds — including why it uses siteSearch and
not the text field Atlassian documents — is in
docs/confluence/search.md.
Usage: markfluence export PAGE [flags]
Write a page and the attachments it uses to a directory — the one-command form
of read plus attachment-download.
$ markfluence export 1234567890 --dest ./out
wrote out/markfluence-test-page.md
downloaded out/assets/diagram.png
(skipped 2 unreferenced attachment(s); --all-attachments to include)The page is written as Markdown with title/space/parent/page_id/
page_width frontmatter — byte-identical to what read prints — so an exported
file can be edited and published straight back with update.
Attachments are written to the paths their images were published from, so the
exported tree matches the layout of the repo the page came from and previews
locally in GitHub or VSCode. There is deliberately no --attachments-dir:
collecting attachments into one directory would mean rewriting the image srcs,
and a later update would then publish them under different attachment names,
orphaning the originals.
Only attachments the page actually references are exported. That includes images,
attachment links, and references inside macros markfluence passes through
untouched. --all-attachments takes everything on the page instead;
--skip-attachments writes the page file only.
--file names the page file, defaulting to a slug of the title
(markfluence-test-page.md), or the page id when the title slugs to nothing.
--dest defaults to the current directory and is created if missing. Existing
files are skipped unless --force, and --dry-run previews without writing.
If the page references an attachment that isn't attached — already broken in Confluence — the export still succeeds and reports it as a warning.
Markdown is the only output format. Use read --format storage to inspect the
raw storage Confluence holds.
Usage: markfluence attachment-list PAGE [flags]
List a page's attachments.
$ markfluence attachment-list 1234567890
NAME SIZE VER TYPE SOURCE
assets%2Fdiagram.png 24.1 KB 3 image/png assets/diagram.png
notes.pdf 1.2 MB 1 application/pdf -NAME is the name Confluence stores. For an image markfluence published that is
the percent-encoded source path (see Body), and SOURCE is the Markdown
image path it came from — so the table shows at a glance which attachments a
publish manages and which it will leave alone.
SOURCE is a dash when no source path is recorded: either the attachment was
uploaded by hand, or it was published before markfluence recorded source paths.
Those two look the same here; --json has a managed field that tells them
apart. Attachments left behind by the encoding change show up this way, which is
how you find them.
Usage: markfluence attachment-upload PAGE FILE... [flags]
Upload or replace attachments on a page, complementing the automatic sync that
create and update perform for a page's images.
Each file is attached under its base name. A file whose contents already match
what's on the page is skipped, using the same checksum bookkeeping
create/update use, so uploading by hand and publishing agree on what's
current. --force uploads anyway (bumping the attachment's version), which is
how you repair an attachment whose stored bytes drifted while its checksum still
matches. --dry-run previews without writing.
--name sets the attachment name for a single file and takes a path, which
markfluence encodes for you — so --name assets/x.png produces the attachment
that an image written as  resolves to. The recorded source
path always matches the stored name, so a later publish won't create a duplicate
under a different one.
markfluence attachment-upload 1234567890 diagram.png
markfluence attachment-upload 1234567890 report.pdf notes.txt
markfluence attachment-upload 1234567890 img.png --name assets/diagram.png
markfluence attachment-upload 1234567890 diagram.png --forceUsage: markfluence attachment-download PAGE [NAME...] [flags]
Download a page's attachments. Each NAME is an attachment name as
attachment-list reports it; with no NAME, every attachment is downloaded.
An attachment markfluence published is written back to the Markdown image path recorded in its comment, so the downloaded tree matches what the page's Markdown references and previews locally:
$ markfluence attachment-download 1234567890 --dest ./out
downloaded /out/assets/diagram.png
downloaded /out/notes.pdfAn attachment with no recorded path — hand-uploaded, or published before
markfluence recorded them — is written under its stored name. --flat writes
everything under stored names. --dest defaults to the current directory and is
created if missing. An existing file is skipped unless --force, and
--dry-run previews without writing.
A recorded path that would resolve outside --dest is refused for that
attachment: the path comes from an attachment comment, which anyone who can edit
the page controls.
The persistent --json flag makes any command emit a single machine-readable
JSON document to stdout instead of the human output, for scripting and CI. It
pipes cleanly to jq:
markfluence info 1234567890 --json | jq '.results[0].page_width'
markfluence update docs/*.md --json | jq '.summary'Output is a stable, versioned envelope. results always holds one object per
target (a single element for info/read); summary carries batch counts:
{
"schema_version": 1,
"markfluence_version": "1.4.0",
"command": "update",
"results": [
{
"ok": true,
"status": "published",
"file": "docs/foo.md",
"page_id": "123",
"title": "Foo",
"space": "ENG",
"url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo",
"version": { "previous": 3, "new": 4 },
"page_width": { "value": "max", "default": false },
"attachments": [ { "action": "updated", "filename": "diagram.png" } ],
"warnings": [],
"broken": [],
"error": null,
"code": null
}
],
"summary": { "total": 1, "succeeded": 1, "failed": 0, "skipped": 0 }
}The full contract is published as a JSON Schema (draft 2020-12) at
schema/json-output/v1.json — the results item
and summary shapes are selected by command, and the stderr error object is
#/$defs/errorObject. A test validates markfluence's actual output against it, so
the schema cannot drift from the implementation.
The binary carries that same schema, so a consumer can fetch the contract
without knowing anything about this repository (see schema).
Notes on the schema:
- Per-command stable. Each command always emits the same keys in the same
shapes (empty values are
nullor[]); the key set differs per command.schema_versionis bumped on any breaking change. - Status verbs are per-command:
published/skipped(update),created/not_created(create),changed/consistent(fix),created/updated/skipped(attachment-upload),downloaded/skipped(attachment-download), plusfailed.info,read, andattachment-listresults carry data only (no status verb). - One result per target, and the target is per-command: the page for
info/read/export(always one), the file forupdate/create/fix, and the attachment for the threeattachment-*commands — so.results[] | .filenameworks andsummary.totalis the attachment count.exportnests the files it wrote in anattachmentsarray on its page result, the wayupdate/createdo. - Compound values are objects, never display strings —
version,page_width, and thecreated/updatedauthor stamps oninfo. create's two-phase abort (a validation failure means nothing is created) lists every input file — failed ones with anerror, the rest asnot_created— and setssummary.aborted: true.- Warnings and broken image/link notices are data (
warnings/brokenarrays on each result), not stderr log lines. - The discovery commands list what they found, so
resultsis one object per match (find,search) or per node (children), andsummary.totalis that count.search's summary carries two extra fields:truncated, meaning--limitwas reached with matches left over, andskipped, counting index rows that had no page id to report (reachable only via--cqlor--type all). Neither is a count of matches you could get by asking again for more.
Errors and exit codes:
-
Per-file operational failures appear in
resultsas{ "ok": false, "error": "…", "code": "…" }; the command exits1if any file failed. -
findandsearchhave no failed-result variant. They name no page, so there is no id to attach a failure to: an operational failure prints the same typed error object to stderr and exits1, with no envelope on stdout. Emitting an emptyresultsarray would be worse than emitting nothing, since "no matches" is a meaningful answer that a caller acts on. -
Fatal/pre-flight failures (bad flags, credential resolution) print a typed error object to stderr and exit
2:{ "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG" } -
Error
codevalues:CONFIG,AUTH,NOT_FOUND,VALIDATION,CONVERT,IO,NETWORK,API.
Usage: markfluence schema
Print the JSON Schema for --json output to stdout. The schema
is embedded in the binary, so a script, a CI job, or an agent can fetch the
contract at runtime — validating markfluence's own output, or generating types
from it — without reading it out of this repository:
$ markfluence schema | jq -r '.properties.command.enum | join(" ")'
info read update create fix attachment-list attachment-upload attachment-download export
$ markfluence update docs/*.md --json > out.json
$ markfluence schema > schema.json
$ check-jsonschema --schemafile schema.json out.jsonThe printed document is byte-identical to
schema/json-output/v1.json at the revision the
binary was built from, and describes the schema_version that binary emits.
Because the tests validate real output against the same embedded copy, the
schema you get from a binary is the one its output was checked against.
Nothing here talks to Confluence, so no credentials are needed. The output is
already JSON; --json changes nothing.
markfluence can run in CI to keep Confluence pages in sync with markdown in
your repo: on a push to your default branch, publish the changed docs. You
will need to know the Confluence page_id for each page you want to update.
Store environment variables as encrypted secret (never commit them).
markfluence reads them straight from the environment — no .env in CI.
CONFLUENCE_TOKENCONFLUENCE_URLCONFLUENCE_USERNAME
Prefer a service account over a personal token here, so published pages
aren't authored by an individual and publishing doesn't break when that person
rotates their token or moves on. That means a scoped token, which also needs
CONFLUENCE_CLOUD_ID (see Scoped tokens and service
accounts). The cloud ID is not sensitive, so
make it a repository variable rather than a secret.
name: Publish docs to Confluence
on:
push:
branches: [main]
paths: ['docs/**.md'] # only when docs change
# Avoid overlapping publishes racing on the same pages.
concurrency:
group: confluence-publish
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
# No release binaries are published yet, so install from source. Pin a tag
# (…@v1.2.3) once releases exist, rather than @latest, for reproducibility.
- name: Install markfluence
run: go install github.com/mozilla/markfluence@latest
- name: Publish
env:
CONFLUENCE_URL: ${{ secrets.CONFLUENCE_URL }}
CONFLUENCE_USERNAME: ${{ secrets.CONFLUENCE_USERNAME }}
CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }}
# A variable, not a secret: the cloud ID is public. Omit it if you're
# using an unscoped personal token.
CONFLUENCE_CLOUD_ID: ${{ vars.CONFLUENCE_CLOUD_ID }}
run:
markfluence update --page-id=12345 --force docs/some_doc.mdNotes:
- Exit codes.
updateexits non-zero if any file fails, so the job fails loudly. Add--jsonto get machine-readable per-file results on stdout (see--jsonoutput) if a later step needs to parse them.
A reusable composite/Docker action wrapping this is tracked in #29.
Each Markdown file is one Confluence page: an optional YAML frontmatter block followed by the Markdown body.
---
title: My Page Title
space: ENG
parent: null
page_id: 1234567890
page_width: max
---
# Body starts here
...
Frontmatter is a block delimited by --- lines containing flat key: value pairs
(no nesting, lists, or multi-line values). Full-line # comments and trailing
inline # ... comments (whitespace, then #) are ignored.
To include a literal # (or leading/trailing whitespace, or a leading quote) in a
value, quote it with single or double quotes — e.g. title: "Detect # Verify".
Single quotes are literal ('' escapes a quote); double quotes honor \" and \\.
markfluence adds quotes automatically when it writes a value back if they're needed
to round-trip.
| Field | Value domain | Notes |
|---|---|---|
space |
a space key (e.g. ENG, or a personal space like ~1234abcd) |
Target space for create (or pass --space); written back by create. Always a key, never a numeric space id. |
parent |
null, a numeric page or folder id, or a relative .md path |
null = top-level page; an id = an existing parent, which may be a page or a Cloud folder (the value is just an id either way — nothing records which kind it is); a .md path = a parent authored in the same run (create resolves it in dependency order, then rewrites the value to <page_id> # <original.md>). Used by create (or --parent). |
page_id |
a numeric page id, or null |
The target page. update looks it up by title and writes it back when missing; create writes it after creating the page. null/absent means "no page yet." |
title |
text (required) | The Confluence page title. |
page_width |
narrow, wide, or max |
The published page width (the UI's "Adjust width" options; narrow/wide/max map to the default/full-width/max appearance properties). Absent or blank defaults to max. create/update assert it on every publish (so a width set in the Confluence UI is overwritten unless the frontmatter matches); fix writes back the live page's width. |
To create a page, you only need to specify the title in the frontmatter.
The body is GitHub-Flavored Markdown, converted to Confluence storage format. Supported constructs:
Fenced code blocks are rendered as Confluence code macros and support the syntax highlighting, but only the languages Confluence supports. GFM fenced code
Tables use GFM syntax and are rendered as Confluence tables. GFM tables
Table cell background colors can be specified using an HTML comment at the start of the cell. They will be invisible in Markdown preview, but will have the specified background color in Confluence.
| Service | Status |
| ------- | -------------------------- |
| auth | <!-- bg:light-green --> ok |
| billing | <!-- bg:light-red --> down |The color is a swatch name from the Confluence editor's cell background palette,
or a literal #rrggbb hex for anything else. The 21 swatches, one row here per
column of the editor's picker:
| Light | Medium | Bold |
|---|---|---|
white #ffffff |
light-grey light-gray #f4f5f7 |
grey gray #b3bac5 |
light-blue #deebff |
blue #b3d4ff |
bold-blue #4c9aff |
light-teal #e6fcff |
teal #b3f5ff |
bold-teal #79e2f2 |
light-green #e3fcef |
green #abf5d1 |
bold-green #57d9a3 |
light-yellow #fffae6 |
yellow #fff0b3 |
bold-yellow #ffc400 |
light-red #ffebe6 |
red #ffbdad |
bold-red #ff8f73 |
light-purple #eae6ff |
purple #c0b6f2 |
bold-purple #998dd9 |
Details:
- Confluence colors cells, not rows or columns; a colored column is implemented with a marker per cell in the column and a colored row is implemented with a marker per cell in the row.
- The marker works in header cells too.
- A cell holding nothing but a marker is an empty colored cell.
- The color marker has to be the first thing in the cell. Anywhere else it's ignored with a warning, since a stray comment would otherwise do nothing visible.
- An unknown color name is dropped with a warning and the cell publishes uncolored.
GitHub alerts — > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING],
[!CAUTION] — become info/tip/note/warning panels.
GFM alerts
Example:
> [!NOTE]
> This is a note.Images —  uploads a local file as an attachment (or
references a remote URL); a missing/unsupported image becomes IMAGE BROKEN: …
text.
Image paths resolve relative to the Markdown file, the same way they do when you view the file on GitHub, so a page in a subdirectory can share an asset directory above it:
docs/ ← run markfluence from here
assets/logo.png
guide/page.md → 
Note
An image path is a URL, not a filename, so a space or other special character
has to be percent-encoded —  for a file named
my image.png. This is the same rule GitHub and your editor's preview follow,
and it is what they produce when they write a link for you.
The angle-bracket form  is an equivalent
spelling of the same image. A bare space () is
not a valid path, so it is not an image at all and stays on the page as
literal text — again matching what GitHub and your preview show.
markfluence read and markfluence export write the encoded form, so a page
round-trips back to Markdown that still renders.
Run markfluence from the root of your documentation tree. That root bounds
which images may be published: an image resolving outside it (../../secrets/x.png)
is reported as IMAGE BROKEN: … (outside the documentation root) rather than
uploaded.
Confluence attachment names cannot contain /, so the path is percent-encoded
into the attachment name — assets/logo.png is attached as assets%2Flogo.png,
and ../assets/logo.png as ..%2Fassets%2Flogo.png. The encoding is reversible,
so markfluence read restores an image's original path instead of a flattened
one. markfluence also records the source path in the attachment's comment, which
it prefers over decoding the name.
Note
Pages published before this encoding existed used / → _. Republishing such
a page uploads the image under its new name and updates the page to match, but
the old attachment stays behind, unreferenced — markfluence never deletes.
Remove those manually if the clutter bothers you.
Extra properties ride in the title as JSON:
alignis left/center/right;width/heightare pixels
A plain title () becomes the image tooltip.
Examples:




Links to sibling .md files are rewritten to the target page's Confluence
URL; heading anchors are rewritten to Confluence's anchor scheme.
As with image paths, a link destination is a URL: a sibling whose filename has a
space is written [see](my%20doc.md) (or [see](<my doc.md>)), and a bare
[see](my doc.md) is not a link at all. The same applies to the fragment, so a
non-ASCII heading anchor may arrive as #caf%C3%A9-section. Both are decoded
before markfluence matches them against files and headings on disk, so either
spelling resolves. A link it cannot resolve — a target with no page_id, or a
file that isn't there — is left exactly as written and published as-is, which on
Confluence is a dead relative link. There is no warning for this, so check the
targets when a link matters.
Comment directives:
<!-- confluence-toc -->— replaced with Confluence table-of-contents macro.<!-- markfluence-version -->— replaced with the build stamp,markfluence VERSION (SHA, DATE)(the same stringmarkfluence --versionprints).
Raw Confluence storage format. You can paste Confluence
storage format
markup (<ac:…> / <ri:…> elements — any macro, layout, etc.) straight from a
page's ⋯ → View storage format into your markdown, and it's emitted verbatim.
Two conventions:
- Leave a blank line between an
ac:/ri:tag and any markdown you want converted (e.g. a macro or layout-cell body). With a blank line the content is parsed as markdown; tight against the tags it passes through literally. - Put the opening tag on its own line (or self-close it) so it isn't wrapped in a paragraph.
For example, a two-column layout with markdown in each cell:
<ac:layout>
<ac:layout-section ac:type="two_equal">
<ac:layout-cell>
Left column with **markdown**.
</ac:layout-cell>
<ac:layout-cell>
Right column.
</ac:layout-cell>
</ac:layout-section>
</ac:layout>
Storage markup shown inside a fenced code block stays literal (it isn't activated).
Requires Go 1.25+.
make build # build ./bin/markfluence
make test # go test ./...
make check # everything CI runs, in CI's order -- the pre-flight before a PRRun make with no target for the full list of rules. Run the built binary
against Confluence by putting a .env in the working directory (see
Configure).
See CONTRIBUTING.md for filing issues, the converter's golden-file regression suite, commit conventions, and what to run before opening a pull request.
pchuri/confluence-cli -- command line interface. markfluence tries to match subcommands and arguments from confluence-cli, but focuses on Markdown document publishing and less on providing a CLI access to the full Confluence v1/v2 API.
kovetskiy/mark -- Markdown support for Confluence and how things are represented. markfluence tries to match key design decisions, but has defaults I like better and works in different scenarios better.