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 needs a 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 (a persistent flag
on every subcommand; a missing explicit path is an error). 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
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 this to publish from CI as a
service account rather than as a person. Scoped tokens are rejected with a
401 against your site domain; they must go through Atlassian's
api.atlassian.com gateway, and the cloud ID is what addresses your site
there. CONFLUENCE_URL still holds the site URL: markfluence needs it to write
correct links into the pages it publishes.
Find your cloud ID — it is not a secret:
$ curl -s https://your-org.atlassian.net/_edge/tenant_info
{"cloudId":"d8febd08-5555-5555-5555-db37c2369ce5"}The scopes markfluence needs:
| Used for | Classic scope |
|---|---|
| Reading, creating, and updating pages | read:confluence-content.all, write:confluence-content |
| Resolving space keys | read:confluence-space.summary |
| Page width (content properties) | read:confluence-props, write:confluence-props |
| Image attachments | write:confluence-file |
Author names in info |
read:confluence-user |
Currently, markfluence doesn't support deleting anything, so it doesn't need delete scopes. This might change in the future.
markfluence --help
markfluence create --help
markfluence update --help
markfluence fix --help
markfluence info --help
markfluence read --helpUsage: 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.
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 page already exists at its
page_id, a title clash in the space, an unresolvable parent), nothing is
created.
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.
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 ARG [flags]
Print a page's metadata (id, title, status, space, parent, version, page width,
authors, dates, url). ARG is a numeric page id 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 ARG [flags]
Fetch a Confluence page and print its body to stdout. ARG is a numeric page id
or a Confluence page URL (the modern /wiki/.../pages/<id>/... form or a legacy
?pageId=<id> URL). 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"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.
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), plusfailed.info/readresults carry data only (no status verb). - 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.
Errors and exit codes:
-
Per-file operational failures appear in
resultsas{ "ok": false, "error": "…", "code": "…" }; the command exits1if any file failed. -
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.
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 id, or a relative .md path |
null = top-level page; a page id = an existing parent page; 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 → 
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.
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+. Common tasks (run make with no target for the list):
make build # build ./bin/markfluence
make test # go test ./...
make lint # golangci-lint (installs the pinned version into ./bin)
make vet # go vet ./...
make fmt # go fmt ./...
make regen-regressions # regenerate the converter's golden test outputsThe converter's behavior is pinned by a golden-file regression suite under
internal/convert/testdata/regression/. Run the built binary against Confluence
by putting a .env in the working directory (see Configure).
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.